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="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
|
||||||
_ident_src="git config mosaic.gitIdentity"
|
_ident_src="git config mosaic.gitIdentity"
|
||||||
fi
|
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
|
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' \
|
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
|
"$_ident" "$MOSAIC_AGENT_NAME" "$host" "$_ident_src" >&2
|
||||||
@@ -542,7 +547,7 @@ get_gitea_token() {
|
|||||||
if [[ -e "$_idcred" || -L "$_idcred" ]]; then
|
if [[ -e "$_idcred" || -L "$_idcred" ]]; then
|
||||||
local _resolved_token
|
local _resolved_token
|
||||||
_resolved_token=$(python3 "$script_dir/resolve-credential-envelope.py" \
|
_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
|
_resolution_path=identity
|
||||||
_trace_credential_resolution credential-resolved "$_ident" "$host" "$_ident_src"
|
_trace_credential_resolution credential-resolved "$_ident" "$host" "$_ident_src"
|
||||||
printf '%s\n' "$_resolved_token"
|
printf '%s\n' "$_resolved_token"
|
||||||
@@ -550,7 +555,8 @@ get_gitea_token() {
|
|||||||
fi
|
fi
|
||||||
if [[ -e "$_idtok" || -L "$_idtok" ]]; then
|
if [[ -e "$_idtok" || -L "$_idtok" ]]; then
|
||||||
local _resolved_token
|
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
|
_resolution_path=identity
|
||||||
_trace_credential_resolution credential-resolved "$_ident" "$host" "$_ident_src"
|
_trace_credential_resolution credential-resolved "$_ident" "$host" "$_ident_src"
|
||||||
printf '%s\n' "$_resolved_token"
|
printf '%s\n' "$_resolved_token"
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ esac
|
|||||||
ident="$MOSAIC_GIT_IDENTITY"
|
ident="$MOSAIC_GIT_IDENTITY"
|
||||||
[ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
|
[ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
|
||||||
[ -z "$ident" ] && ident="$username_in"
|
[ -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
|
if [ -n "$idpfx" ] && [ -n "${MOSAIC_AGENT_NAME:-}" ] && [ -n "$ident" ] && [ "$ident" != "$MOSAIC_AGENT_NAME" ]; then
|
||||||
echo "quit=true"
|
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' \
|
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"
|
idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.credential.json"
|
||||||
if [ -e "$idcred" ] || [ -L "$idcred" ]; then
|
if [ -e "$idcred" ] || [ -L "$idcred" ]; then
|
||||||
token=$(python3 "$script_dir/resolve-credential-envelope.py" \
|
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
|
resolution_path=identity
|
||||||
trace_resolution credential-resolved "$ident" "$host" git-credential-mosaic
|
trace_resolution credential-resolved "$ident" "$host" git-credential-mosaic
|
||||||
echo "username=${ident}"
|
echo "username=${ident}"
|
||||||
@@ -67,7 +72,8 @@ if [ -n "$ident" ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
if [ -e "$idtok" ] || [ -L "$idtok" ]; then
|
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
|
resolution_path=identity
|
||||||
trace_resolution credential-resolved "$ident" "$host" git-credential-mosaic
|
trace_resolution credential-resolved "$ident" "$host" git-credential-mosaic
|
||||||
echo "username=${ident}"
|
echo "username=${ident}"
|
||||||
|
|||||||
@@ -27,9 +27,11 @@ def refuse(message: str) -> None:
|
|||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
if len(sys.argv) != 5:
|
if len(sys.argv) != 6:
|
||||||
refuse("expected path, identity, estate, and host")
|
refuse("expected governed root, path, identity, estate, and host")
|
||||||
path, identity, estate, host = sys.argv[1:]
|
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:
|
if not estate:
|
||||||
refuse("explicit estate is required")
|
refuse("explicit estate is required")
|
||||||
parent = os.path.dirname(path)
|
parent = os.path.dirname(path)
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ def refuse(message: str) -> None:
|
|||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
if len(sys.argv) != 2:
|
if len(sys.argv) != 3:
|
||||||
refuse("expected token path")
|
refuse("expected governed root and token path")
|
||||||
path = sys.argv[1]
|
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)
|
parent = os.path.dirname(path)
|
||||||
try:
|
try:
|
||||||
parent_stat = os.stat(parent, follow_symlinks=False)
|
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 {
|
import type {
|
||||||
CredentialValidationDependencies,
|
CredentialValidationDependencies,
|
||||||
GiteaReadValidationRequestDto,
|
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(
|
export async function runCredentialReadValidation(
|
||||||
request: GiteaReadValidationRequestDto,
|
request: GiteaReadValidationRequestDto,
|
||||||
dependencies: CredentialValidationDependencies,
|
dependencies: CredentialValidationDependencies,
|
||||||
options: CredentialValidationServiceOptions,
|
options: CredentialValidationServiceOptions,
|
||||||
): Promise<CredentialValidationResultDto> {
|
): Promise<CredentialValidationResultDto> {
|
||||||
const journal = await openValidationJournal(request, options);
|
const journal = await openValidationJournal(request, options);
|
||||||
const validation = await evaluateGiteaReadValidation(request, dependencies);
|
try {
|
||||||
return recordAndSealValidation(journal, {
|
const validation = await evaluateGiteaReadValidation(request, dependencies);
|
||||||
...validation,
|
return await recordAndSealValidation(journal, {
|
||||||
operation: options.operation ?? 'validate',
|
...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(
|
export async function runCredentialValidation(
|
||||||
@@ -85,6 +129,13 @@ export async function runCredentialValidation(
|
|||||||
options: CredentialValidationServiceOptions,
|
options: CredentialValidationServiceOptions,
|
||||||
): Promise<CredentialValidationResultDto> {
|
): Promise<CredentialValidationResultDto> {
|
||||||
const journal = await openValidationJournal(request, options);
|
const journal = await openValidationJournal(request, options);
|
||||||
const validation = await evaluateGiteaWriteValidation(request, dependencies);
|
try {
|
||||||
return recordAndSealValidation(journal, validation);
|
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',
|
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';
|
mutation = 'unknown';
|
||||||
await provider.revokeToken(authority, request.identity, binding.tokenName);
|
await provider.revokeToken(authority, request.identity, binding.tokenName);
|
||||||
mutation = 'applied';
|
mutation = 'applied';
|
||||||
@@ -323,7 +341,16 @@ export async function revokeCredential(
|
|||||||
auditState: 'sealed',
|
auditState: 'sealed',
|
||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} 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');
|
await journal.seal('indeterminate', 'mutation-state-unknown');
|
||||||
return lifecycleResult('revoke', request, {
|
return lifecycleResult('revoke', request, {
|
||||||
outcome: 'indeterminate',
|
outcome: 'indeterminate',
|
||||||
|
|||||||
@@ -42,6 +42,15 @@ const loginSchema = z
|
|||||||
.passthrough();
|
.passthrough();
|
||||||
const configSchema = z.object({ logins: z.array(loginSchema).default([]) }).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>>> {
|
async function acquireLock(path: string): Promise<Awaited<ReturnType<typeof open>>> {
|
||||||
for (let attempt = 0; attempt < 500; attempt += 1) {
|
for (let attempt = 0; attempt < 500; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
@@ -122,6 +131,7 @@ export class TeaLoginStore {
|
|||||||
await handle.close();
|
await handle.close();
|
||||||
}
|
}
|
||||||
await rename(temp, this.configPath);
|
await rename(temp, this.configPath);
|
||||||
|
await syncDirectory(directory);
|
||||||
} finally {
|
} finally {
|
||||||
await lock.close();
|
await lock.close();
|
||||||
await unlink(lockPath).catch((): void => undefined);
|
await unlink(lockPath).catch((): void => undefined);
|
||||||
@@ -192,6 +202,7 @@ export class TeaLoginStore {
|
|||||||
await handle.close();
|
await handle.close();
|
||||||
}
|
}
|
||||||
await rename(temp, this.configPath);
|
await rename(temp, this.configPath);
|
||||||
|
await syncDirectory(directory);
|
||||||
} finally {
|
} finally {
|
||||||
await lock.close();
|
await lock.close();
|
||||||
await unlink(lockPath).catch((): void => undefined);
|
await unlink(lockPath).catch((): void => undefined);
|
||||||
|
|||||||
Reference in New Issue
Block a user