fix(mosaic): bind delegated lifecycle evidence

This commit is contained in:
2026-08-05 18:01:41 -05:00
parent b3d84662f9
commit b3a6959e46
7 changed files with 124 additions and 19 deletions
@@ -525,6 +525,11 @@ get_gitea_token() {
_ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
_ident_src="git config mosaic.gitIdentity"
fi
if [[ -n "$_ident" && ! "$_ident" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]; then
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=invalid-identity identity=<invalid> host=%s shared_path_entered=false source=%s\n' \
"$host" "$_ident_src" >&2
return 1
fi
if [[ -n "${MOSAIC_AGENT_NAME:-}" && -n "$_ident" && "$_ident" != "$MOSAIC_AGENT_NAME" ]]; then
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=provider-identity-mismatch identity=%s fleet_identity=%s host=%s shared_path_entered=false source=%s\n' \
"$_ident" "$MOSAIC_AGENT_NAME" "$host" "$_ident_src" >&2
@@ -542,7 +547,7 @@ get_gitea_token() {
if [[ -e "$_idcred" || -L "$_idcred" ]]; then
local _resolved_token
_resolved_token=$(python3 "$script_dir/resolve-credential-envelope.py" \
"$_idcred" "$_ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || return 1
"$HOME/.config/mosaic/secrets/gitea-tokens" "$_idcred" "$_ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || return 1
_resolution_path=identity
_trace_credential_resolution credential-resolved "$_ident" "$host" "$_ident_src"
printf '%s\n' "$_resolved_token"
@@ -550,7 +555,8 @@ get_gitea_token() {
fi
if [[ -e "$_idtok" || -L "$_idtok" ]]; then
local _resolved_token
_resolved_token=$(python3 "$script_dir/resolve-legacy-token.py" "$_idtok") || return 1
_resolved_token=$(python3 "$script_dir/resolve-legacy-token.py" \
"$HOME/.config/mosaic/secrets/gitea-tokens" "$_idtok") || return 1
_resolution_path=identity
_trace_credential_resolution credential-resolved "$_ident" "$host" "$_ident_src"
printf '%s\n' "$_resolved_token"
@@ -47,6 +47,11 @@ esac
ident="$MOSAIC_GIT_IDENTITY"
[ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
[ -z "$ident" ] && ident="$username_in"
if [[ -n "$ident" && ! "$ident" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]; then
echo "quit=true"
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=invalid-identity identity=<invalid> host=%s shared_path_entered=false source=git-credential-mosaic\n' "$host" >&2
exit 1
fi
if [ -n "$idpfx" ] && [ -n "${MOSAIC_AGENT_NAME:-}" ] && [ -n "$ident" ] && [ "$ident" != "$MOSAIC_AGENT_NAME" ]; then
echo "quit=true"
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=provider-identity-mismatch identity=%s fleet_identity=%s host=%s shared_path_entered=false source=git-credential-mosaic\n' \
@@ -59,7 +64,7 @@ if [ -n "$ident" ]; then
idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.credential.json"
if [ -e "$idcred" ] || [ -L "$idcred" ]; then
token=$(python3 "$script_dir/resolve-credential-envelope.py" \
"$idcred" "$ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || exit 1
"$HOME/.config/mosaic/secrets/gitea-tokens" "$idcred" "$ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || exit 1
resolution_path=identity
trace_resolution credential-resolved "$ident" "$host" git-credential-mosaic
echo "username=${ident}"
@@ -67,7 +72,8 @@ if [ -n "$ident" ]; then
exit 0
fi
if [ -e "$idtok" ] || [ -L "$idtok" ]; then
token=$(python3 "$script_dir/resolve-legacy-token.py" "$idtok") || exit 1
token=$(python3 "$script_dir/resolve-legacy-token.py" \
"$HOME/.config/mosaic/secrets/gitea-tokens" "$idtok") || exit 1
resolution_path=identity
trace_resolution credential-resolved "$ident" "$host" git-credential-mosaic
echo "username=${ident}"
@@ -27,9 +27,11 @@ def refuse(message: str) -> None:
raise SystemExit(1)
if len(sys.argv) != 5:
refuse("expected path, identity, estate, and host")
path, identity, estate, host = sys.argv[1:]
if len(sys.argv) != 6:
refuse("expected governed root, path, identity, estate, and host")
root, path, identity, estate, host = sys.argv[1:]
if os.path.abspath(os.path.dirname(path)) != os.path.abspath(root):
refuse("credential is not a direct child of the governed root")
if not estate:
refuse("explicit estate is required")
parent = os.path.dirname(path)
@@ -13,9 +13,11 @@ def refuse(message: str) -> None:
raise SystemExit(1)
if len(sys.argv) != 2:
refuse("expected token path")
path = sys.argv[1]
if len(sys.argv) != 3:
refuse("expected governed root and token path")
root, path = sys.argv[1:]
if os.path.abspath(os.path.dirname(path)) != os.path.abspath(root):
refuse("credential is not a direct child of the governed root")
parent = os.path.dirname(path)
try:
parent_stat = os.stat(parent, follow_symlinks=False)
@@ -1,4 +1,4 @@
import { CredentialAuditJournal } from './audit-journal.js';
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
import type {
CredentialValidationDependencies,
GiteaReadValidationRequestDto,
@@ -66,17 +66,61 @@ async function recordAndSealValidation(
};
}
function journalFailureResult(
request: GiteaReadValidationRequestDto,
journal: CredentialAuditJournal,
error: CredentialJournalError,
operation: 'validate' | 'whoami',
): CredentialValidationResultDto {
return {
schemaVersion: 1,
operation,
outcome: 'error',
exitCode: 20,
retryable: false,
subject: {
identity: request.identity,
estate: request.estate,
host: request.host,
repo: request.repo,
},
mutation: 'none',
reason: {
code: error.code,
message: 'Validation audit persistence failed; inspect the durable open journal.',
},
evidence: {
providerIdentity: null,
tokenCapabilities: {
state: 'not-measured',
scopes: [],
source: 'runtime-not-authorized',
},
repositoryPermission: null,
writeDifferential: null,
},
audit: { journalId: journal.journalId(), state: 'open' },
};
}
export async function runCredentialReadValidation(
request: GiteaReadValidationRequestDto,
dependencies: CredentialValidationDependencies,
options: CredentialValidationServiceOptions,
): Promise<CredentialValidationResultDto> {
const journal = await openValidationJournal(request, options);
const validation = await evaluateGiteaReadValidation(request, dependencies);
return recordAndSealValidation(journal, {
...validation,
operation: options.operation ?? 'validate',
});
try {
const validation = await evaluateGiteaReadValidation(request, dependencies);
return await recordAndSealValidation(journal, {
...validation,
operation: options.operation ?? 'validate',
});
} catch (error: unknown) {
if (error instanceof CredentialJournalError) {
return journalFailureResult(request, journal, error, options.operation ?? 'validate');
}
throw error;
}
}
export async function runCredentialValidation(
@@ -85,6 +129,13 @@ export async function runCredentialValidation(
options: CredentialValidationServiceOptions,
): Promise<CredentialValidationResultDto> {
const journal = await openValidationJournal(request, options);
const validation = await evaluateGiteaWriteValidation(request, dependencies);
return recordAndSealValidation(journal, validation);
try {
const validation = await evaluateGiteaWriteValidation(request, dependencies);
return await recordAndSealValidation(journal, validation);
} catch (error: unknown) {
if (error instanceof CredentialJournalError) {
return journalFailureResult(request, journal, error, 'validate');
}
throw error;
}
}
+28 -1
View File
@@ -292,6 +292,24 @@ export async function revokeCredential(
auditState: 'sealed',
});
}
const identity = await provider.readBasicIdentity(authority);
if (identity.login !== request.identity || authority.identity !== request.identity) {
await journal.seal('refused', 'provider-identity-mismatch');
return lifecycleResult('revoke', request, {
outcome: 'refused',
mutation: 'none',
code: 'provider-identity-mismatch',
message: 'Delegated Basic authority did not bind the requested principal.',
journalId: journal.journalId(),
auditState: 'sealed',
providerIdentity: identity.login,
});
}
await journal.recordProviderEvidence({
endpoint: identity.endpoint,
contentType: identity.contentType,
decision: 'identity-verified',
});
mutation = 'unknown';
await provider.revokeToken(authority, request.identity, binding.tokenName);
mutation = 'applied';
@@ -323,7 +341,16 @@ export async function revokeCredential(
auditState: 'sealed',
});
} catch (error: unknown) {
if (error instanceof CredentialJournalError) throw error;
if (error instanceof CredentialJournalError) {
return lifecycleResult('revoke', request, {
outcome: 'indeterminate',
mutation,
code: error.code,
message: 'Audit persistence failed; inspect the durable open journal before recovery.',
journalId: journal.journalId(),
auditState: 'open',
});
}
await journal.seal('indeterminate', 'mutation-state-unknown');
return lifecycleResult('revoke', request, {
outcome: 'indeterminate',
@@ -42,6 +42,15 @@ const loginSchema = z
.passthrough();
const configSchema = z.object({ logins: z.array(loginSchema).default([]) }).passthrough();
async function syncDirectory(path: string): Promise<void> {
const handle = await open(path, 'r');
try {
await handle.sync();
} finally {
await handle.close();
}
}
async function acquireLock(path: string): Promise<Awaited<ReturnType<typeof open>>> {
for (let attempt = 0; attempt < 500; attempt += 1) {
try {
@@ -122,6 +131,7 @@ export class TeaLoginStore {
await handle.close();
}
await rename(temp, this.configPath);
await syncDirectory(directory);
} finally {
await lock.close();
await unlink(lockPath).catch((): void => undefined);
@@ -192,6 +202,7 @@ export class TeaLoginStore {
await handle.close();
}
await rename(temp, this.configPath);
await syncDirectory(directory);
} finally {
await lock.close();
await unlink(lockPath).catch((): void => undefined);