fix(mosaic): fail closed across lifecycle audit faults
This commit is contained in:
@@ -504,23 +504,6 @@ export async function executeCredentialRotate(
|
||||
});
|
||||
}
|
||||
const context = await lifecycleContext(options);
|
||||
const old = await context.store.snapshot(identity, options.estate, options.host);
|
||||
if (old === undefined) {
|
||||
return localLifecycleResult('rotate', identity, options, {
|
||||
outcome: 'refused',
|
||||
code: 'no-token-for-identity',
|
||||
message: 'No existing binding can be rotated.',
|
||||
});
|
||||
}
|
||||
if (options.tokenName === old.binding.tokenName) {
|
||||
old.secret.fill(0);
|
||||
return localLifecycleResult('rotate', identity, options, {
|
||||
outcome: 'refused',
|
||||
code: 'replacement-token-name-conflict',
|
||||
message: 'Replacement token name must differ from the active generation.',
|
||||
});
|
||||
}
|
||||
const authority = await lifecycleAuthority(identity, options);
|
||||
const journal = await CredentialAuditJournal.open(context.stateRoot, {
|
||||
operation: 'rotate',
|
||||
actor: options.actor,
|
||||
@@ -530,6 +513,25 @@ export async function executeCredentialRotate(
|
||||
repo: null,
|
||||
});
|
||||
await journal.recordIntent('rotate-requested');
|
||||
const old = await context.store.snapshot(identity, options.estate, options.host);
|
||||
if (old === undefined) {
|
||||
await journal.seal('refused', 'no-token-for-identity');
|
||||
return localLifecycleResult('rotate', identity, options, {
|
||||
outcome: 'refused',
|
||||
code: 'no-token-for-identity',
|
||||
message: 'No existing binding can be rotated.',
|
||||
});
|
||||
}
|
||||
if (options.tokenName === old.binding.tokenName) {
|
||||
old.secret.fill(0);
|
||||
await journal.seal('refused', 'replacement-token-name-conflict');
|
||||
return localLifecycleResult('rotate', identity, options, {
|
||||
outcome: 'refused',
|
||||
code: 'replacement-token-name-conflict',
|
||||
message: 'Replacement token name must differ from the active generation.',
|
||||
});
|
||||
}
|
||||
const authority = await lifecycleAuthority(identity, options);
|
||||
const provisioned = await provisionCredential(
|
||||
{
|
||||
identity,
|
||||
@@ -655,16 +657,17 @@ export async function executeCredentialWire(
|
||||
message: 'Declared estate and host could not be resolved before mutation.',
|
||||
});
|
||||
}
|
||||
const journal = await CredentialAuditJournal.open(locations.stateRoot, {
|
||||
operation: 'wire',
|
||||
actor: options.actor,
|
||||
identity,
|
||||
estate: options.estate,
|
||||
host: options.host,
|
||||
repo: null,
|
||||
});
|
||||
await journal.recordIntent('wire-requested');
|
||||
let journal: CredentialAuditJournal | undefined;
|
||||
try {
|
||||
journal = await CredentialAuditJournal.open(locations.stateRoot, {
|
||||
operation: 'wire',
|
||||
actor: options.actor,
|
||||
identity,
|
||||
estate: options.estate,
|
||||
host: options.host,
|
||||
repo: null,
|
||||
});
|
||||
await journal.recordIntent('wire-requested');
|
||||
let existing = '';
|
||||
try {
|
||||
const snapshot = readRegularFileSecure(options.seatEnv, {
|
||||
@@ -719,15 +722,32 @@ export async function executeCredentialWire(
|
||||
message: 'Both fleet identity axes were written to the explicit seat environment.',
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
});
|
||||
} catch {
|
||||
await journal.seal('error', 'wire-failed');
|
||||
return localLifecycleResult('wire', identity, options, {
|
||||
outcome: 'error',
|
||||
mutation: 'unknown',
|
||||
code: 'wire-failed',
|
||||
message: 'Seat environment wiring failed.',
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (journal === undefined) {
|
||||
return localLifecycleResult('wire', identity, options, {
|
||||
outcome: 'error',
|
||||
code: error instanceof CredentialJournalError ? error.code : 'wire-failed',
|
||||
message: 'Seat environment journal could not be opened durably.',
|
||||
});
|
||||
}
|
||||
try {
|
||||
await journal.seal('error', 'wire-failed');
|
||||
return localLifecycleResult('wire', identity, options, {
|
||||
outcome: 'error',
|
||||
mutation: 'unknown',
|
||||
code: 'wire-failed',
|
||||
message: 'Seat environment wiring failed.',
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
});
|
||||
} catch (sealError: unknown) {
|
||||
return localLifecycleResult('wire', identity, options, {
|
||||
outcome: 'error',
|
||||
mutation: 'unknown',
|
||||
code: sealError instanceof CredentialJournalError ? sealError.code : 'wire-failed',
|
||||
message: 'Seat environment audit could not be sealed.',
|
||||
audit: { journalId: journal.journalId(), state: 'open' },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -736,16 +756,17 @@ export async function executeCredentialGet(
|
||||
options: CredentialLifecycleCommandOptions,
|
||||
): Promise<CredentialLifecycleResultDto> {
|
||||
const locations = lifecycleLocations(options);
|
||||
const journal = await CredentialAuditJournal.open(locations.stateRoot, {
|
||||
operation: 'get',
|
||||
actor: options.actor,
|
||||
identity,
|
||||
estate: options.estate,
|
||||
host: options.host,
|
||||
repo: null,
|
||||
});
|
||||
await journal.recordIntent('get-requested');
|
||||
let journal: CredentialAuditJournal | undefined;
|
||||
try {
|
||||
journal = await CredentialAuditJournal.open(locations.stateRoot, {
|
||||
operation: 'get',
|
||||
actor: options.actor,
|
||||
identity,
|
||||
estate: options.estate,
|
||||
host: options.host,
|
||||
repo: null,
|
||||
});
|
||||
await journal.recordIntent('get-requested');
|
||||
if (options.authorityFd === undefined || options.actor !== identity) {
|
||||
await journal.seal('refused', 'provider-identity-mismatch');
|
||||
return localLifecycleResult('get', identity, options, {
|
||||
@@ -810,14 +831,30 @@ export async function executeCredentialGet(
|
||||
message: 'Credential was emitted only to the protected output fd.',
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
});
|
||||
} catch {
|
||||
await journal.seal('error', 'insecure-credential-destination');
|
||||
return localLifecycleResult('get', identity, options, {
|
||||
outcome: 'error',
|
||||
code: 'insecure-credential-destination',
|
||||
message: 'Protected credential output fd was unavailable or unsafe.',
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (journal === undefined) {
|
||||
return localLifecycleResult('get', identity, options, {
|
||||
outcome: 'error',
|
||||
code: error instanceof CredentialJournalError ? error.code : 'journal-unavailable',
|
||||
message: 'Credential access journal could not be opened durably.',
|
||||
});
|
||||
}
|
||||
try {
|
||||
await journal.seal('error', 'insecure-credential-destination');
|
||||
return localLifecycleResult('get', identity, options, {
|
||||
outcome: 'error',
|
||||
code: 'insecure-credential-destination',
|
||||
message: 'Protected credential output fd was unavailable or unsafe.',
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
});
|
||||
} catch (sealError: unknown) {
|
||||
return localLifecycleResult('get', identity, options, {
|
||||
outcome: 'error',
|
||||
code: sealError instanceof CredentialJournalError ? sealError.code : 'journal-unavailable',
|
||||
message: 'Credential access audit could not be sealed.',
|
||||
audit: { journalId: journal.journalId(), state: 'open' },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -826,16 +863,17 @@ async function executeAuthorizedInventoryRead(
|
||||
options: CredentialLifecycleCommandOptions,
|
||||
): Promise<CredentialLifecycleResultDto> {
|
||||
const locations = lifecycleLocations(options);
|
||||
const journal = await CredentialAuditJournal.open(locations.stateRoot, {
|
||||
operation,
|
||||
actor: options.actor,
|
||||
identity: options.actor,
|
||||
estate: options.estate,
|
||||
host: options.host,
|
||||
repo: null,
|
||||
});
|
||||
await journal.recordIntent(`${operation}-requested`);
|
||||
let journal: CredentialAuditJournal | undefined;
|
||||
try {
|
||||
journal = await CredentialAuditJournal.open(locations.stateRoot, {
|
||||
operation,
|
||||
actor: options.actor,
|
||||
identity: options.actor,
|
||||
estate: options.estate,
|
||||
host: options.host,
|
||||
repo: null,
|
||||
});
|
||||
await journal.recordIntent(`${operation}-requested`);
|
||||
if (options.authorityFd === undefined) {
|
||||
await journal.seal('refused', 'authority-required');
|
||||
return localLifecycleResult(operation, 'all', options, {
|
||||
@@ -891,14 +929,30 @@ async function executeAuthorizedInventoryRead(
|
||||
},
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
});
|
||||
} catch {
|
||||
await journal.seal('error', 'inventory-read-failed');
|
||||
return localLifecycleResult(operation, 'all', options, {
|
||||
outcome: 'error',
|
||||
code: 'inventory-read-failed',
|
||||
message: 'Authorized credential inventory read failed.',
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (journal === undefined) {
|
||||
return localLifecycleResult(operation, 'all', options, {
|
||||
outcome: 'error',
|
||||
code: error instanceof CredentialJournalError ? error.code : 'journal-unavailable',
|
||||
message: 'Inventory journal could not be opened durably.',
|
||||
});
|
||||
}
|
||||
try {
|
||||
await journal.seal('error', 'inventory-read-failed');
|
||||
return localLifecycleResult(operation, 'all', options, {
|
||||
outcome: 'error',
|
||||
code: 'inventory-read-failed',
|
||||
message: 'Authorized credential inventory read failed.',
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
});
|
||||
} catch (sealError: unknown) {
|
||||
return localLifecycleResult(operation, 'all', options, {
|
||||
outcome: 'error',
|
||||
code: sealError instanceof CredentialJournalError ? sealError.code : 'journal-unavailable',
|
||||
message: 'Inventory audit could not be sealed.',
|
||||
audit: { journalId: journal.journalId(), state: 'open' },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -222,6 +222,25 @@ export class FileCredentialResolver implements CredentialResolver {
|
||||
}
|
||||
}
|
||||
|
||||
function assertPrivateTokenDirectory(path: string): {
|
||||
readonly dev: number | bigint;
|
||||
readonly ino: number | bigint;
|
||||
} {
|
||||
const stat = lstatSync(path);
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== process.getuid?.() ||
|
||||
(stat.mode & 0o022) !== 0
|
||||
) {
|
||||
throw new CredentialStoreError(
|
||||
'insecure-token-owner',
|
||||
'token directory ownership or write permissions are unsafe',
|
||||
);
|
||||
}
|
||||
return { dev: stat.dev, ino: stat.ino };
|
||||
}
|
||||
|
||||
async function syncDirectory(path: string): Promise<void> {
|
||||
const handle = await open(path, 'r');
|
||||
try {
|
||||
@@ -269,6 +288,7 @@ export class FileCredentialStore {
|
||||
async put(metadata: CredentialBindingMetadataDto, secret: Uint8Array): Promise<void> {
|
||||
const paths = this.paths(metadata.identity, metadata.estate, metadata.host);
|
||||
ensureManagedDirectory(this.tokenDirectory, this.tokenDirectory);
|
||||
const directoryIdentity = assertPrivateTokenDirectory(this.tokenDirectory);
|
||||
const token = validateSecret(Buffer.from(secret));
|
||||
const envelope = credentialEnvelopeSchema.parse({
|
||||
...metadata,
|
||||
@@ -296,6 +316,16 @@ export class FileCredentialStore {
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
const beforeCommit = assertPrivateTokenDirectory(this.tokenDirectory);
|
||||
if (
|
||||
beforeCommit.dev !== directoryIdentity.dev ||
|
||||
beforeCommit.ino !== directoryIdentity.ino
|
||||
) {
|
||||
throw new CredentialStoreError(
|
||||
'insecure-token-owner',
|
||||
'token directory changed during credential commit',
|
||||
);
|
||||
}
|
||||
await rename(envelopeTemp, paths.envelope);
|
||||
await syncDirectory(this.tokenDirectory);
|
||||
} finally {
|
||||
@@ -424,6 +454,7 @@ export class FileCredentialStore {
|
||||
expectedTokenDigest?: string,
|
||||
): Promise<void> {
|
||||
const paths = this.paths(identity, estate, host);
|
||||
const directoryIdentity = assertPrivateTokenDirectory(this.tokenDirectory);
|
||||
const lockPath = join(this.tokenDirectory, `${paths.prefix}.lock`);
|
||||
let lock;
|
||||
try {
|
||||
@@ -444,6 +475,16 @@ export class FileCredentialStore {
|
||||
);
|
||||
}
|
||||
}
|
||||
const beforeRemoval = assertPrivateTokenDirectory(this.tokenDirectory);
|
||||
if (
|
||||
beforeRemoval.dev !== directoryIdentity.dev ||
|
||||
beforeRemoval.ino !== directoryIdentity.ino
|
||||
) {
|
||||
throw new CredentialStoreError(
|
||||
'insecure-token-owner',
|
||||
'token directory changed during credential removal',
|
||||
);
|
||||
}
|
||||
await unlink(paths.token).catch((error: unknown): void => {
|
||||
if (!isMissingFile(error)) throw error;
|
||||
});
|
||||
|
||||
@@ -389,6 +389,33 @@ export class GiteaGrantProviderAdapter
|
||||
extends GiteaCredentialProviderAdapter
|
||||
implements GiteaGrantProvider
|
||||
{
|
||||
async readBasicIdentity(authority: ResolvedCredential): Promise<ProviderIdentityEvidenceDto> {
|
||||
const endpoint = 'GET /api/v1/user';
|
||||
const response = await this.request(`${this.origin}/api/v1/user`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: JSON_CONTENT_TYPE,
|
||||
Authorization: basicAuthorization(authority),
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
await boundedBody(response);
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'credential-rejected',
|
||||
'delegated Basic authority was rejected',
|
||||
);
|
||||
}
|
||||
const parsed = userSchema.safeParse(await jsonObject(response));
|
||||
if (!parsed.success) {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'unexpected-provider-shape',
|
||||
'delegated Basic identity response was malformed',
|
||||
);
|
||||
}
|
||||
return { login: parsed.data.login, endpoint, contentType: contentType(response) };
|
||||
}
|
||||
|
||||
async grantCollaborator(
|
||||
authority: ResolvedCredential,
|
||||
identity: string,
|
||||
@@ -402,7 +429,7 @@ export class GiteaGrantProviderAdapter
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Accept: JSON_CONTENT_TYPE,
|
||||
Authorization: apiAuthorization(authority),
|
||||
Authorization: basicAuthorization(authority),
|
||||
'Content-Type': JSON_CONTENT_TYPE,
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
@@ -433,7 +460,7 @@ export class GiteaGrantProviderAdapter
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: JSON_CONTENT_TYPE,
|
||||
Authorization: apiAuthorization(authority),
|
||||
Authorization: basicAuthorization(authority),
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
},
|
||||
@@ -518,7 +545,7 @@ export class GiteaTeamGrantProviderAdapter
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: JSON_CONTENT_TYPE,
|
||||
Authorization: apiAuthorization(authority),
|
||||
Authorization: basicAuthorization(authority),
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
},
|
||||
@@ -557,7 +584,7 @@ export class GiteaTeamGrantProviderAdapter
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: JSON_CONTENT_TYPE,
|
||||
Authorization: apiAuthorization(authority),
|
||||
Authorization: basicAuthorization(authority),
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
},
|
||||
@@ -613,7 +640,7 @@ export class GiteaTeamGrantProviderAdapter
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Accept: JSON_CONTENT_TYPE,
|
||||
Authorization: apiAuthorization(authority),
|
||||
Authorization: basicAuthorization(authority),
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
});
|
||||
@@ -666,7 +693,7 @@ export class GiteaTeamGrantProviderAdapter
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: JSON_CONTENT_TYPE,
|
||||
Authorization: apiAuthorization(authority),
|
||||
Authorization: basicAuthorization(authority),
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -83,7 +83,7 @@ describe('direct repository grant', (): void => {
|
||||
it('opens the journal before mutation and accepts only matching provider read-back', async (): Promise<void> => {
|
||||
const root = await stateRoot();
|
||||
const provider: GiteaGrantProvider = {
|
||||
async readIdentity() {
|
||||
async readBasicIdentity() {
|
||||
return {
|
||||
login: 'provisioner',
|
||||
endpoint: 'GET /api/v1/user',
|
||||
@@ -142,7 +142,7 @@ describe('direct repository grant', (): void => {
|
||||
it('preserves applied mutation and journal context when post-grant read-back fails', async (): Promise<void> => {
|
||||
const root = await stateRoot();
|
||||
const provider: GiteaGrantProvider = {
|
||||
async readIdentity() {
|
||||
async readBasicIdentity() {
|
||||
return {
|
||||
login: 'provisioner',
|
||||
endpoint: 'GET /api/v1/user',
|
||||
@@ -183,7 +183,7 @@ describe('direct repository grant', (): void => {
|
||||
it('is indeterminate when grant read-back disagrees with the requested permission', async (): Promise<void> => {
|
||||
const root = await stateRoot();
|
||||
const provider: GiteaGrantProvider = {
|
||||
async readIdentity() {
|
||||
async readBasicIdentity() {
|
||||
return {
|
||||
login: 'provisioner',
|
||||
endpoint: 'GET /api/v1/user',
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
import { evaluateGiteaReadValidation, evaluateGiteaWriteValidation } from './validate.js';
|
||||
|
||||
export interface GiteaGrantProvider {
|
||||
readIdentity(authority: ResolvedCredential): Promise<{
|
||||
readBasicIdentity(authority: ResolvedCredential): Promise<{
|
||||
readonly login: string;
|
||||
readonly endpoint: string;
|
||||
readonly contentType: string;
|
||||
@@ -76,7 +76,7 @@ export async function grantDirectRepositoryPermission(
|
||||
await journal.recordIntent('provider-grant');
|
||||
let mutation: 'none' | 'unknown' | 'applied' = 'none';
|
||||
try {
|
||||
const authorityIdentity = await grantProvider.readIdentity(authority);
|
||||
const authorityIdentity = await grantProvider.readBasicIdentity(authority);
|
||||
if (authorityIdentity.login !== options.actor) {
|
||||
await journal.seal('refused', 'provider-identity-mismatch');
|
||||
return {
|
||||
|
||||
@@ -74,7 +74,7 @@ describe('team repository grant', (): void => {
|
||||
it('reads team permission, org membership, member attachment, repo attachment, and effective subject permission', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
||||
const provider: GiteaTeamGrantProvider = {
|
||||
async readIdentity() {
|
||||
async readBasicIdentity() {
|
||||
return {
|
||||
login: 'provisioner',
|
||||
endpoint: 'GET /api/v1/user',
|
||||
@@ -148,7 +148,7 @@ describe('team repository grant', (): void => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
||||
let mutated = false;
|
||||
const provider: GiteaTeamGrantProvider = {
|
||||
async readIdentity() {
|
||||
async readBasicIdentity() {
|
||||
return {
|
||||
login: 'provisioner',
|
||||
endpoint: 'GET /api/v1/user',
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface TeamGrantResult extends CredentialGrantResultDto {
|
||||
};
|
||||
}
|
||||
export interface GiteaTeamGrantProvider {
|
||||
readIdentity(
|
||||
readBasicIdentity(
|
||||
authority: ResolvedCredential,
|
||||
): Promise<{ readonly login: string; readonly endpoint: string; readonly contentType: string }>;
|
||||
resolveTeam(
|
||||
@@ -93,7 +93,7 @@ export async function grantTeamRepositoryPermission(
|
||||
await journal.recordIntent('provider-grant');
|
||||
let mutation: 'none' | 'unknown' | 'applied' = 'none';
|
||||
try {
|
||||
const authorityIdentity = await provider.readIdentity(authority);
|
||||
const authorityIdentity = await provider.readBasicIdentity(authority);
|
||||
const organization = request.repo.split('/')[0] ?? '';
|
||||
const team = await provider.resolveTeam(authority, organization, request.team);
|
||||
if (authorityIdentity.login !== options.actor || team.permission !== request.permission) {
|
||||
|
||||
Reference in New Issue
Block a user