fix(mosaic): bind delegated lifecycle evidence
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user