feat(mosaic): complete credential lifecycle and fail closed

This commit is contained in:
2026-08-05 13:11:04 -05:00
committed by Hermes Agent
parent 2419313286
commit 050ac63737
27 changed files with 1946 additions and 29 deletions
+5 -3
View File
@@ -51,7 +51,7 @@ No explicit token cap supplied. Working cap: keep implementation in one package
- [x] Contract v1.4 implements ruling (b): subject credential's own `/user`, no admin/inventory authority, no implemented `identity-not-found` path. - [x] Contract v1.4 implements ruling (b): subject credential's own `/user`, no admin/inventory authority, no implemented `identity-not-found` path.
- [x] PRD update. - [x] PRD update.
- [x] Red-first principal-bound validate, estate-registry, file-store, provider-transport, and journal tests. - [x] Red-first principal-bound validate, estate-registry, file-store, provider-transport, and journal tests.
- [ ] Implementation (validate core/CLI, direct/team grant core, and protected delegated-authority fd reader in progress; provider team adapter, CLI grant integration, remaining lifecycle commands, and separately sequenced fail-closed resolver evidence open). - [ ] Implementation (validate, direct/team grant, protected delegated authority, provision/wire/get/whoami/list/rotate/revoke/audit, and fleet fail-closed resolver paths implemented; final review hardening and live provider lifecycle evidence open).
- [ ] Independent code/security reviews. - [ ] Independent code/security reviews.
- [ ] CI and provider evidence. - [ ] CI and provider evidence.
@@ -65,7 +65,9 @@ Red-first evidence:
- read validation absent → 2 tests failed `evaluateGiteaReadValidation is not a function`; after implementation, 15/15 validate tests passed; - read validation absent → 2 tests failed `evaluateGiteaReadValidation is not a function`; after implementation, 15/15 validate tests passed;
- estate registry, secure file resolver, Gitea transport, and audit journal each failed first because the module did not exist, then passed focused behavior suites. - estate registry, secure file resolver, Gitea transport, and audit journal each failed first because the module did not exist, then passed focused behavior suites.
Current focused evidence after v1.4: provider + validate 23/23, durable correction journal 5/5, delegated credential fd 2/2, direct grant 2/2, team grant 1/1; package typecheck green. Current focused evidence: 62/62 across 9 credential suites; package lint and typecheck green. Provider bodies are stream-bounded and requests deadline-bounded; delegated fd input is ownership/mode/size/time bounded; token and Tea stores are private and atomic; grant mutation/read-back state is journaled.
Fail-closed resolver evidence: synthetic missing-token API and git paths each emitted stable `MOSAIC_CREDENTIAL_REFUSAL` with `reason=no-token-for-identity` and `shared_path_entered=false`; all 13 live token-bearing identities bypassed the shared path without over-fire in the same run. Evidence: `/home/hermes/agent-work/be-coder-06/review-evidence/failclosed-postcondition.jsonl`; independent verification remains tl-mosaic's obligation.
Live validation v1.4 (subject credential's own `/user`, no admin): population 13; CONFIRMED 8; CREDENTIAL-REJECTED 4 (`coder-mos1`, `coder-mos2`, `f10-coder`, `merge-gate`); MISMATCH 1 (`mos-admin` token authenticates as `Mos`); NOT-MEASURED 0. The four false v1.2 `identity-not-found` sealed journals remain immutable and are explicitly superseded by four sealed correction journals. Evidence: `/home/hermes/agent-work/be-coder-06/live-validation-v1.4/`. Live validation v1.4 (subject credential's own `/user`, no admin): population 13; CONFIRMED 8; CREDENTIAL-REJECTED 4 (`coder-mos1`, `coder-mos2`, `f10-coder`, `merge-gate`); MISMATCH 1 (`mos-admin` token authenticates as `Mos`); NOT-MEASURED 0. The four false v1.2 `identity-not-found` sealed journals remain immutable and are explicitly superseded by four sealed correction journals. Evidence: `/home/hermes/agent-work/be-coder-06/live-validation-v1.4/`.
@@ -75,5 +77,5 @@ Write differential for be-coder-06 passed with the configured read-only control
- The full CLI surface is broad; protect scope by sharing one provider/registry/journal core rather than per-command scripts. - The full CLI surface is broad; protect scope by sharing one provider/registry/journal core rather than per-command scripts.
- Gitea exact token-scope read-back may require delegated Basic Auth. If a bearer-only validation path cannot obtain an exact provider token object, return `indeterminate` rather than claim a scope. - Gitea exact token-scope read-back may require delegated Basic Auth. If a bearer-only validation path cannot obtain an exact provider token object, return `indeterminate` rather than claim a scope.
- #1044 hold is LIFTED: the four credentials are already rejected and fail-open preserves silent misattribution. Re-mint is separate fleet task #19. The fail-closed change still requires normal review/green plus mechanism evidence proving resolver refusal, a same-run marker-emission positive control, and unaffected confirmed lanes; tl-mosaic independently verifies. - #1044 hold is LIFTED. The four least-privilege credentials are capability-confirmed and identity-not-measured, not dead. Fleet fail-closed paths now refuse with stable reason markers and never enter shared fallback under `MOSAIC_AGENT_NAME`; interactive callers retain explicit shared behavior. Runtime mismatch coverage remains limited to tokens holding `read:user`; future mints close identity binding at creation without widening seat scopes.
- Branch model compatibility remains escalated above this lane. Do not claim completion at `next`. - Branch model compatibility remains escalated above this lane. Do not claim completion at `next`.
@@ -534,12 +534,21 @@ get_gitea_token() {
# would post PRs/issues/reviews under the WRONG agent (e.g. rev2's review attributed # would post PRs/issues/reviews under the WRONG agent (e.g. rev2's review attributed
# to coder3), corrupting Gate-16 author≠reviewer separation. Hard-stop instead so the # to coder3), corrupting Gate-16 author≠reviewer separation. Hard-stop instead so the
# caller aborts loudly rather than acting as the wrong identity. # caller aborts loudly rather than acting as the wrong identity.
echo "Error: git identity '$_ident' requested (via $_ident_src) for host '$host', but no per-slot token at $_idtok." >&2 printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=no-token-for-identity identity=%s host=%s shared_path_entered=false source=%s path=%s\n' \
echo " Refusing to borrow another slot's token. Provision the per-slot token, or unset the identity to use shared credentials." >&2 "$_ident" "$host" "$_ident_src" "$_idtok" >&2
return 1 return 1
fi fi
fi fi
# Fleet automation never borrows a shared human/default credential. An
# explicit interactive caller may still reach the shared paths below, but
# a fleet process must name an identity and resolve that identity exactly.
if [[ -n "${MOSAIC_AGENT_NAME:-}" ]]; then
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=identity-required identity=<unset> host=%s shared_path_entered=false source=MOSAIC_AGENT_NAME\n' \
"$host" >&2
return 1
fi
# 1. Mosaic credential loader (host → service mapping, run in subshell to avoid polluting env) # 1. Mosaic credential loader (host → service mapping, run in subshell to avoid polluting env)
if [[ -f "$cred_loader" ]]; then if [[ -f "$cred_loader" ]]; then
local token local token
@@ -45,8 +45,23 @@ if [ -n "$ident" ]; then
echo "password=$(cat "$idtok")" echo "password=$(cat "$idtok")"
exit 0 exit 0
fi fi
if [ -n "${MOSAIC_AGENT_NAME:-}" ]; then
echo "quit=true"
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=no-token-for-identity identity=%s host=%s shared_path_entered=false source=git-credential-mosaic path=%s\n' \
"$ident" "$host" "$idtok" >&2
exit 1
fi
fi fi
fi fi
if [ -n "${MOSAIC_AGENT_NAME:-}" ] && [ -z "$ident" ]; then
case "$host" in
git.uscllc.com|git.mosaicstack.dev)
echo "quit=true"
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=identity-required identity=<unset> host=%s shared_path_entered=false source=git-credential-mosaic\n' "$host" >&2
exit 1
;;
esac
fi
case "$host" in case "$host" in
git.uscllc.com) svc=gitea-usc;; git.uscllc.com) svc=gitea-usc;;
git.mosaicstack.dev) svc=gitea-mosaicstack;; git.mosaicstack.dev) svc=gitea-mosaicstack;;
@@ -85,6 +85,16 @@ out=$(run_helper "git.mosaicstack.dev" "")
assert_eq "shared fallback: username" "username=git" "$(echo "$out" | grep '^username=')" assert_eq "shared fallback: username" "username=git" "$(echo "$out" | grep '^username=')"
assert_eq "shared fallback: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')" assert_eq "shared fallback: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
set +e
out=$(run_helper "git.mosaicstack.dev" "" MOSAIC_AGENT_NAME=synthetic-seat 2>"$WORK_DIR/fleet-unset.stderr")
rc=$?
set -e
err=$(cat "$WORK_DIR/fleet-unset.stderr")
if [[ "$rc" -eq 0 || "$out" != *"quit=true"* || "$err" != *"reason=identity-required"* || "$err" != *"shared_path_entered=false"* ]]; then
echo "FAIL: fleet unset identity did not stop at the resolver marker" >&2
fail=1
fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 2. git-supplied username resolves to an identity WITH a per-slot token -> # 2. git-supplied username resolves to an identity WITH a per-slot token ->
# that identity + token wins over the shared account. # that identity + token wins over the shared account.
@@ -120,6 +130,16 @@ out=$(run_helper "git.mosaicstack.dev" "no-such-agent")
assert_eq "no per-slot token: username" "username=git" "$(echo "$out" | grep '^username=')" assert_eq "no per-slot token: username" "username=git" "$(echo "$out" | grep '^username=')"
assert_eq "no per-slot token: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')" assert_eq "no per-slot token: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
set +e
out=$(run_helper "git.mosaicstack.dev" "no-such-agent" MOSAIC_AGENT_NAME=synthetic-seat 2>"$WORK_DIR/fleet-missing.stderr")
rc=$?
set -e
err=$(cat "$WORK_DIR/fleet-missing.stderr")
if [[ "$rc" -eq 0 || "$out" != *"quit=true"* || "$err" != *"reason=no-token-for-identity"* || "$err" != *"shared_path_entered=false"* ]]; then
echo "FAIL: fleet missing token did not stop at the resolver marker" >&2
fail=1
fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 6. Correct per-slot token PATH is chosen per host: same agent id, different # 6. Correct per-slot token PATH is chosen per host: same agent id, different
# host prefix (gitea-usc- vs gitea-mosaicstack-). # host prefix (gitea-usc- vs gitea-mosaicstack-).
@@ -85,7 +85,23 @@ call_get_gitea_token() {
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
out=$(call_get_gitea_token "git.mosaicstack.dev") out=$(call_get_gitea_token "git.mosaicstack.dev")
assert_eq "shared fallback (no identity)" "shared-mosaicstack-token" "$out" assert_eq "interactive shared fallback (no identity)" "shared-mosaicstack-token" "$out"
# Fleet context with no explicit identity refuses before the shared path. This
# is the marker-emission positive control for the fail-closed mechanism.
set +e
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_AGENT_NAME=synthetic-seat 2>"$WORK_DIR/stderr-fleet-unset.tmp")
rc=$?
set -e
err=$(cat "$WORK_DIR/stderr-fleet-unset.tmp")
if [[ "$rc" -eq 0 || -n "$out" ]]; then
echo "FAIL: fleet unset identity must refuse with empty stdout" >&2
fail=1
fi
if [[ "$err" != *"MOSAIC_CREDENTIAL_REFUSAL"* || "$err" != *"reason=identity-required"* || "$err" != *"shared_path_entered=false"* ]]; then
echo "FAIL: fleet unset identity did not emit the stable resolver refusal marker: $err" >&2
fail=1
fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 2. git config mosaic.gitIdentity resolves to an agent WITH a per-slot # 2. git config mosaic.gitIdentity resolves to an agent WITH a per-slot
@@ -93,8 +109,8 @@ assert_eq "shared fallback (no identity)" "shared-mosaicstack-token" "$out"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentA.token" echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentA.token"
git -C "$REPO_DIR" config mosaic.gitIdentity agentA git -C "$REPO_DIR" config mosaic.gitIdentity agentA
out=$(call_get_gitea_token "git.mosaicstack.dev") out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_AGENT_NAME=agentA)
assert_eq "git-config identity token" "agentA-mosaicstack-token" "$out" assert_eq "confirmed fleet identity bypasses shared path" "agentA-mosaicstack-token" "$out"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 3. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity. # 3. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
@@ -143,13 +159,17 @@ assert_failloud() {
echo "$stderr" >&2 echo "$stderr" >&2
fail=1 fail=1
fi fi
if [[ "$stderr" != *"MOSAIC_CREDENTIAL_REFUSAL"* || "$stderr" != *"reason=no-token-for-identity"* || "$stderr" != *"shared_path_entered=false"* ]]; then
echo "FAIL: $desc — stable resolver refusal marker missing: $stderr" >&2
fail=1
fi
if [[ "$stderr" != *"$expected_tok_path"* ]]; then if [[ "$stderr" != *"$expected_tok_path"* ]]; then
echo "FAIL: $desc — stderr does not name the expected per-slot token path '$expected_tok_path':" >&2 echo "FAIL: $desc — stderr does not name the expected per-slot token path '$expected_tok_path':" >&2
echo "$stderr" >&2 echo "$stderr" >&2
fail=1 fail=1
fi fi
if [[ "$stderr" == *"shared"*"token"* ]]; then if [[ "$stderr" == *"shared-mosaicstack-token"* || "$stderr" == *"shared-usc-token"* ]]; then
echo "FAIL: $desc — stderr unexpectedly mentions a shared token value:" >&2 echo "FAIL: $desc — stderr unexpectedly contains a shared credential value:" >&2
echo "$stderr" >&2 echo "$stderr" >&2
fail=1 fail=1
fi fi
+572 -2
View File
@@ -1,7 +1,13 @@
import { fstatSync, writeSync } from 'node:fs';
import { open, readFile, rename } from 'node:fs/promises';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import type { Command } from 'commander'; import type { Command } from 'commander';
import { CredentialJournalError } from '../credentials/audit-journal.js'; import {
CredentialAuditJournal,
CredentialJournalError,
listCredentialJournals,
} from '../credentials/audit-journal.js';
import { readRegularFileSecure } from '../fleet/secure-file.js'; import { readRegularFileSecure } from '../fleet/secure-file.js';
import { import {
runCredentialReadValidation, runCredentialReadValidation,
@@ -20,6 +26,9 @@ import {
import type { CredentialGrantResultDto } from '../credentials/grant.dto.js'; import type { CredentialGrantResultDto } from '../credentials/grant.dto.js';
import { grantTeamRepositoryPermission } from '../credentials/team-grant.js'; import { grantTeamRepositoryPermission } from '../credentials/team-grant.js';
import type { TeamGrantResult } from '../credentials/team-grant.js'; import type { TeamGrantResult } from '../credentials/team-grant.js';
import { provisionCredential, revokeCredential } from '../credentials/lifecycle.js';
import type { CredentialLifecycleResultDto } from '../credentials/lifecycle.dto.js';
import { TeaLoginStore } from '../credentials/tea-login-store.js';
import { import {
CredentialEstateRegistryError, CredentialEstateRegistryError,
parseCredentialEstateRegistry, parseCredentialEstateRegistry,
@@ -27,9 +36,11 @@ import {
import { import {
CredentialStoreError, CredentialStoreError,
FileCredentialResolver, FileCredentialResolver,
FileCredentialStore,
} from '../credentials/file-credential-store.js'; } from '../credentials/file-credential-store.js';
import { import {
GiteaCredentialProviderAdapter, GiteaCredentialProviderAdapter,
GiteaLifecycleProviderAdapter,
GiteaTeamGrantProviderAdapter, GiteaTeamGrantProviderAdapter,
} from '../credentials/gitea-provider.js'; } from '../credentials/gitea-provider.js';
@@ -64,6 +75,23 @@ interface CredentialGrantCommandOptions {
readonly json?: boolean; readonly json?: boolean;
} }
interface CredentialLifecycleCommandOptions {
readonly estate: string;
readonly host: string;
readonly actor: string;
readonly authorityFd?: string;
readonly outputFd?: string;
readonly scopes?: string;
readonly tokenName?: string;
readonly registry?: string;
readonly tokenDir?: string;
readonly stateDir?: string;
readonly seatEnv?: string;
readonly teaConfig?: string;
readonly mosaicHome?: string;
readonly json?: boolean;
}
function defaultMosaicHome(options: { readonly mosaicHome?: string }): string { function defaultMosaicHome(options: { readonly mosaicHome?: string }): string {
return options.mosaicHome ?? join(homedir(), '.config', 'mosaic'); return options.mosaicHome ?? join(homedir(), '.config', 'mosaic');
} }
@@ -281,8 +309,414 @@ export async function executeCredentialGrant(
} }
} }
function lifecycleLocations(options: CredentialLifecycleCommandOptions): {
readonly registryPath: string;
readonly tokenDirectory: string;
readonly stateRoot: string;
} {
const mosaicHome = defaultMosaicHome(options);
return {
registryPath: options.registry ?? join(mosaicHome, 'cred', 'estates.json'),
tokenDirectory:
options.tokenDir ??
process.env['MOSAIC_GITEA_TOKEN_DIR'] ??
join(mosaicHome, 'secrets', 'gitea-tokens'),
stateRoot: options.stateDir ?? join(homedir(), '.local', 'state', 'mosaic', 'cred'),
};
}
function localLifecycleResult(
operation: CredentialLifecycleResultDto['operation'],
identity: string,
options: CredentialLifecycleCommandOptions,
values: Partial<CredentialLifecycleResultDto> & {
readonly outcome: CredentialLifecycleResultDto['outcome'];
readonly code: string;
readonly message: string;
},
): CredentialLifecycleResultDto {
const exits = { ok: 0, refused: 10, error: 20, indeterminate: 30 } as const;
return {
schemaVersion: 1,
operation,
outcome: values.outcome,
exitCode: exits[values.outcome],
retryable: false,
subject: { identity, estate: options.estate, host: options.host, repo: null },
mutation: values.mutation ?? 'none',
reason: { code: values.code, message: values.message },
evidence: values.evidence ?? {
providerIdentity: null,
token: null,
teaLogin: null,
identities: [],
journalIds: [],
},
audit: values.audit ?? { journalId: null, state: 'not-started' },
};
}
async function lifecycleContext(options: CredentialLifecycleCommandOptions): Promise<{
readonly registry: ReturnType<typeof parseCredentialEstateRegistry>;
readonly store: FileCredentialStore;
readonly provider: GiteaLifecycleProviderAdapter;
readonly teaStore: TeaLoginStore;
readonly stateRoot: string;
}> {
const locations = lifecycleLocations(options);
const registry = parseCredentialEstateRegistry(readRegistrySource(locations.registryPath));
const host = registry.resolve(options.estate, options.host);
if (host === undefined) {
throw new CredentialEstateRegistryError('estate-host-mismatch', 'estate and host do not match');
}
return {
registry,
store: new FileCredentialStore(locations.tokenDirectory, registry),
provider: new GiteaLifecycleProviderAdapter(host.apiBaseUrl, fetch),
teaStore: new TeaLoginStore(
options.teaConfig ?? join(homedir(), '.config', 'tea', 'config.yml'),
),
stateRoot: locations.stateRoot,
};
}
async function lifecycleAuthority(
identity: string,
options: CredentialLifecycleCommandOptions,
): Promise<Awaited<ReturnType<typeof readDelegatedCredentialFromFd>>> {
return readDelegatedCredentialFromFd(
Number(options.authorityFd),
options.actor,
options.estate,
options.host,
);
}
export async function executeCredentialProvision(
identity: string,
options: CredentialLifecycleCommandOptions,
): Promise<CredentialLifecycleResultDto> {
try {
if (options.authorityFd === undefined || options.tokenName === undefined) {
return localLifecycleResult('provision', identity, options, {
outcome: 'error',
code: 'invalid-input',
message: 'Authority fd and token name are required.',
});
}
const scopes = (options.scopes ?? '').split(',').filter(Boolean);
if (scopes.length === 0) {
return localLifecycleResult('provision', identity, options, {
outcome: 'error',
code: 'invalid-input',
message: 'At least one explicit token scope is required.',
});
}
const context = await lifecycleContext(options);
const authority = await lifecycleAuthority(identity, options);
return await provisionCredential(
{
identity,
estate: options.estate,
host: options.host,
tokenName: options.tokenName,
scopes,
},
authority,
context.provider,
context.store,
context.teaStore,
{ stateRoot: context.stateRoot, actor: options.actor },
);
} catch (error: unknown) {
const code =
error instanceof CredentialJournalError ||
error instanceof CredentialEstateRegistryError ||
error instanceof CredentialStoreError
? error.code
: 'internal-invariant';
return localLifecycleResult('provision', identity, options, {
outcome: 'error',
code,
message: 'Provisioning control failed locally.',
});
}
}
export async function executeCredentialRevoke(
identity: string,
options: CredentialLifecycleCommandOptions,
): Promise<CredentialLifecycleResultDto> {
try {
if (options.authorityFd === undefined) {
return localLifecycleResult('revoke', identity, options, {
outcome: 'error',
code: 'invalid-input',
message: 'Authority fd is required.',
});
}
const context = await lifecycleContext(options);
const authority = await lifecycleAuthority(identity, options);
return await revokeCredential(
{ identity, estate: options.estate, host: options.host },
authority,
context.provider,
context.store,
{ stateRoot: context.stateRoot, actor: options.actor },
);
} catch {
return localLifecycleResult('revoke', identity, options, {
outcome: 'error',
code: 'internal-invariant',
message: 'Revocation control failed locally.',
});
}
}
export async function executeCredentialRotate(
identity: string,
options: CredentialLifecycleCommandOptions,
): Promise<CredentialLifecycleResultDto> {
try {
if (options.authorityFd === undefined || options.tokenName === undefined) {
return localLifecycleResult('rotate', identity, options, {
outcome: 'error',
code: 'invalid-input',
message: 'Authority fd and new token name are required.',
});
}
const context = await lifecycleContext(options);
const old = await context.store.readBinding(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.',
});
}
const authority = await lifecycleAuthority(identity, options);
const journal = await CredentialAuditJournal.open(context.stateRoot, {
operation: 'rotate',
actor: options.actor,
identity,
estate: options.estate,
host: options.host,
repo: null,
});
await journal.recordIntent('rotate-requested');
const provisioned = await provisionCredential(
{
identity,
estate: options.estate,
host: options.host,
tokenName: options.tokenName,
scopes: (options.scopes ?? old.scopes.join(',')).split(',').filter(Boolean),
},
authority,
context.provider,
context.store,
context.teaStore,
{ stateRoot: context.stateRoot, actor: options.actor },
);
if (provisioned.outcome !== 'ok') {
await journal.seal(provisioned.outcome, provisioned.reason.code);
return {
...provisioned,
operation: 'rotate',
audit: { journalId: journal.journalId(), state: 'sealed' },
};
}
await journal.recordMutation('token-mint-applied');
await context.provider.revokeToken(authority, identity, old.tokenName);
await journal.recordMutation('token-revoke-applied');
await journal.seal('ok', 'rotate-verified');
return {
...provisioned,
operation: 'rotate',
reason: {
code: 'rotate-verified',
message: 'New token was read back before old token revocation.',
},
audit: { journalId: journal.journalId(), state: 'sealed' },
};
} catch {
return localLifecycleResult('rotate', identity, options, {
outcome: 'indeterminate',
mutation: 'unknown',
code: 'mutation-state-unknown',
message: 'Rotation did not establish both new-token acceptance and old-token revocation.',
});
}
}
export async function executeCredentialWire(
identity: string,
options: CredentialLifecycleCommandOptions,
): Promise<CredentialLifecycleResultDto> {
if (options.seatEnv === undefined) {
return localLifecycleResult('wire', identity, options, {
outcome: 'error',
code: 'invalid-input',
message: 'An explicit seat environment file is required.',
});
}
const locations = lifecycleLocations(options);
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');
try {
const existing = await readFile(options.seatEnv, 'utf8').catch((): string => '');
const lines = existing
.split(/\r?\n/)
.filter(
(line): boolean =>
!line.startsWith('MOSAIC_GIT_IDENTITY=') && !line.startsWith('GITEA_LOGIN='),
);
lines.push(`MOSAIC_GIT_IDENTITY=${identity}`, `GITEA_LOGIN=${identity}`);
const temp = `${options.seatEnv}.${process.pid.toString()}.tmp`;
const handle = await open(temp, 'wx', 0o600);
try {
await handle.writeFile(`${lines.filter(Boolean).join('\n')}\n`, 'utf8');
await handle.sync();
} finally {
await handle.close();
}
await rename(temp, options.seatEnv);
await journal.recordMutation('wire-applied');
await journal.seal('ok', 'wire-verified');
return localLifecycleResult('wire', identity, options, {
outcome: 'ok',
mutation: 'applied',
code: 'wire-verified',
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' },
});
}
}
export async function executeCredentialGet(
identity: string,
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');
try {
const fd = Number(options.outputFd);
const stat = fstatSync(fd);
if (
!Number.isSafeInteger(fd) ||
fd < 3 ||
stat.uid !== process.getuid?.() ||
(stat.mode & 0o077) !== 0
) {
throw new Error('unsafe output fd');
}
const context = await lifecycleContext(options);
const resolved = await new FileCredentialResolver(
lifecycleLocations(options).tokenDirectory,
context.registry,
).resolve(identity, options.estate, options.host);
if (resolved === undefined) {
await journal.seal('refused', 'no-token-for-identity');
return localLifecycleResult('get', identity, options, {
outcome: 'refused',
code: 'no-token-for-identity',
message: 'No exact governed credential exists.',
audit: { journalId: journal.journalId(), state: 'sealed' },
});
}
const prefix = new TextEncoder().encode(
`protocol=https\nhost=${options.host}\nusername=${identity}\npassword=`,
);
writeSync(fd, prefix);
writeSync(fd, resolved.secret);
writeSync(fd, new TextEncoder().encode('\n\n'));
await journal.recordMutation('credential-issued');
await journal.seal('ok', 'get-verified');
return localLifecycleResult('get', identity, options, {
outcome: 'ok',
code: 'get-verified',
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' },
});
}
}
export async function executeCredentialList(
options: CredentialLifecycleCommandOptions,
): Promise<CredentialLifecycleResultDto> {
try {
const context = await lifecycleContext(options);
const identities = await context.store.list(options.estate, options.host);
return localLifecycleResult('list', 'all', options, {
outcome: 'ok',
code: 'list-verified',
message: 'Governed credential bindings were listed without secrets.',
evidence: { providerIdentity: null, token: null, teaLogin: null, identities, journalIds: [] },
});
} catch {
return localLifecycleResult('list', 'all', options, {
outcome: 'error',
code: 'internal-invariant',
message: 'Credential listing failed.',
});
}
}
export async function executeCredentialAudit(
options: CredentialLifecycleCommandOptions,
): Promise<CredentialLifecycleResultDto> {
const journals = await listCredentialJournals(lifecycleLocations(options).stateRoot);
return localLifecycleResult('audit', 'all', options, {
outcome: 'ok',
code: 'audit-verified',
message: 'Durable journal index was read.',
evidence: {
providerIdentity: null,
token: null,
teaLogin: null,
identities: [],
journalIds: journals.map((journal): string => journal.id),
},
});
}
type PrintableCredentialResult = Pick< type PrintableCredentialResult = Pick<
CredentialValidationResultDto | CredentialGrantResultDto | TeamGrantResult, | CredentialValidationResultDto
| CredentialGrantResultDto
| TeamGrantResult
| CredentialLifecycleResultDto,
'operation' | 'outcome' | 'exitCode' | 'reason' 'operation' | 'outcome' | 'exitCode' | 'reason'
>; >;
@@ -306,6 +740,125 @@ export function registerCredentialCommand(parent: Command): void {
cred.outputHelp(); cred.outputHelp();
}); });
cred
.command('provision <identity>')
.description('Mint, scope-read-back, store, and bind a provider credential')
.requiredOption('--estate <estate>', 'Explicit target estate')
.requiredOption('--host <host>', 'Explicit provider host')
.requiredOption('--actor <identity>', 'Explicit delegated Basic authority identity')
.requiredOption('--authority-fd <fd>', 'Inherited protected Basic credential fd')
.requiredOption('--token-name <name>', 'Unique provider token name')
.requiredOption('--scopes <scopes>', 'Comma-separated least-privilege token scopes')
.option('--tea-config <path>', 'Tea login configuration path')
.option('--registry <path>', 'Strict non-secret estate registry')
.option('--token-dir <path>', 'Governed token directory')
.option('--state-dir <path>', 'Durable credential journal root')
.option('--json', 'Emit one machine result object')
.action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise<void> => {
const result = await executeCredentialProvision(identity, options);
printCredentialResult(result, options.json === true);
process.exitCode = result.exitCode;
});
cred
.command('wire <identity>')
.description('Idempotently wire both explicit fleet identity axes into a seat environment')
.requiredOption('--estate <estate>', 'Explicit target estate')
.requiredOption('--host <host>', 'Explicit provider host')
.requiredOption('--actor <identity>', 'Explicit audit actor')
.requiredOption('--seat-env <path>', 'Explicit seat environment file')
.option('--state-dir <path>', 'Durable credential journal root')
.option('--json', 'Emit one machine result object')
.action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise<void> => {
const result = await executeCredentialWire(identity, options);
printCredentialResult(result, options.json === true);
process.exitCode = result.exitCode;
});
cred
.command('get <identity>')
.description('Emit one exact credential only to a protected inherited fd')
.requiredOption('--estate <estate>', 'Explicit target estate')
.requiredOption('--host <host>', 'Explicit provider host')
.requiredOption('--actor <identity>', 'Explicit audit actor')
.requiredOption('--output-fd <fd>', 'Protected inherited credential output fd')
.option('--registry <path>', 'Strict non-secret estate registry')
.option('--token-dir <path>', 'Governed token directory')
.option('--state-dir <path>', 'Durable credential journal root')
.option('--json', 'Emit one non-secret machine result object')
.action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise<void> => {
const result = await executeCredentialGet(identity, options);
printCredentialResult(result, options.json === true);
process.exitCode = result.exitCode;
});
cred
.command('rotate <identity>')
.description('Read back a replacement token before revoking the old token')
.requiredOption('--estate <estate>', 'Explicit target estate')
.requiredOption('--host <host>', 'Explicit provider host')
.requiredOption('--actor <identity>', 'Explicit delegated Basic authority identity')
.requiredOption('--authority-fd <fd>', 'Inherited protected Basic credential fd')
.requiredOption('--token-name <name>', 'Unique replacement provider token name')
.option('--scopes <scopes>', 'Replacement scopes; defaults to existing exact scopes')
.option('--tea-config <path>', 'Tea login configuration path')
.option('--registry <path>', 'Strict non-secret estate registry')
.option('--token-dir <path>', 'Governed token directory')
.option('--state-dir <path>', 'Durable credential journal root')
.option('--json', 'Emit one machine result object')
.action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise<void> => {
const result = await executeCredentialRotate(identity, options);
printCredentialResult(result, options.json === true);
process.exitCode = result.exitCode;
});
cred
.command('revoke <identity>')
.description('Revoke the provider token before removing its governed local binding')
.requiredOption('--estate <estate>', 'Explicit target estate')
.requiredOption('--host <host>', 'Explicit provider host')
.requiredOption('--actor <identity>', 'Explicit delegated Basic authority identity')
.requiredOption('--authority-fd <fd>', 'Inherited protected Basic credential fd')
.option('--registry <path>', 'Strict non-secret estate registry')
.option('--token-dir <path>', 'Governed token directory')
.option('--state-dir <path>', 'Durable credential journal root')
.option('--json', 'Emit one machine result object')
.action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise<void> => {
const result = await executeCredentialRevoke(identity, options);
printCredentialResult(result, options.json === true);
process.exitCode = result.exitCode;
});
cred
.command('list')
.description('List governed identities without reading or printing secret material')
.requiredOption('--estate <estate>', 'Explicit target estate')
.requiredOption('--host <host>', 'Explicit provider host')
.requiredOption('--actor <identity>', 'Explicit audit actor')
.option('--registry <path>', 'Strict non-secret estate registry')
.option('--token-dir <path>', 'Governed token directory')
.option('--state-dir <path>', 'Durable credential journal root')
.option('--json', 'Emit one machine result object')
.action(async (options: CredentialLifecycleCommandOptions): Promise<void> => {
const result = await executeCredentialList(options);
printCredentialResult(result, options.json === true);
process.exitCode = result.exitCode;
});
cred
.command('audit')
.description('List durable credential journals without secret-bearing payloads')
.requiredOption('--estate <estate>', 'Explicit target estate')
.requiredOption('--host <host>', 'Explicit provider host')
.requiredOption('--actor <identity>', 'Explicit audit actor')
.option('--state-dir <path>', 'Durable credential journal root')
.option('--json', 'Emit one machine result object')
.action(async (options: CredentialLifecycleCommandOptions): Promise<void> => {
const result = await executeCredentialAudit(options);
printCredentialResult(result, options.json === true);
process.exitCode = result.exitCode;
});
cred cred
.command('grant <identity>') .command('grant <identity>')
.description('Grant repository permission and accept only provider object read-back') .description('Grant repository permission and accept only provider object read-back')
@@ -354,4 +907,21 @@ export function registerCredentialCommand(parent: Command): void {
printCredentialResult(result, options.json === true); printCredentialResult(result, options.json === true);
process.exitCode = result.exitCode; process.exitCode = result.exitCode;
}); });
cred
.command('whoami <identity>')
.description('Read back runtime identity when scoped, otherwise report identity not measured')
.requiredOption('--estate <estate>', 'Explicit target estate')
.requiredOption('--host <host>', 'Explicit provider host')
.requiredOption('--repo <owner/repo>', 'In-scope capability repository')
.option('--registry <path>', 'Strict non-secret estate registry')
.option('--token-dir <path>', 'Governed token directory')
.option('--state-dir <path>', 'Durable credential journal root')
.option('--actor <identity>', 'Explicit audit actor')
.option('--json', 'Emit one machine result object')
.action(async (identity: string, options: CredentialValidateCommandOptions): Promise<void> => {
const result = await executeCredentialValidate(identity, { ...options, require: 'read' });
printCredentialResult(result, options.json === true);
process.exitCode = result.exitCode;
});
} }
@@ -5,7 +5,10 @@ export type CredentialJournalOperation =
| 'get' | 'get'
| 'validate' | 'validate'
| 'rotate' | 'rotate'
| 'revoke'; | 'revoke'
| 'whoami'
| 'list'
| 'audit';
export interface CredentialJournalContextDto { export interface CredentialJournalContextDto {
readonly operation: CredentialJournalOperation; readonly operation: CredentialJournalOperation;
@@ -51,7 +51,7 @@ describe('credential durable audit journal', (): void => {
it('leaves an unsealed journal visible for recovery', async (): Promise<void> => { it('leaves an unsealed journal visible for recovery', async (): Promise<void> => {
const root = await stateRoot(); const root = await stateRoot();
await CredentialAuditJournal.open(root, { const journal = await CredentialAuditJournal.open(root, {
operation: 'rotate', operation: 'rotate',
actor: 'provisioner', actor: 'provisioner',
identity: 'seat-name', identity: 'seat-name',
@@ -64,6 +64,7 @@ describe('credential durable audit journal', (): void => {
expect(journals).toHaveLength(1); expect(journals).toHaveLength(1);
expect(journals[0]?.state).toBe('open'); expect(journals[0]?.state).toBe('open');
await journal.closeIncomplete();
}); });
it('fails fatally when the durable journal root cannot be created', async (): Promise<void> => { it('fails fatally when the durable journal root cannot be created', async (): Promise<void> => {
@@ -103,6 +104,7 @@ describe('credential durable audit journal', (): void => {
const journals = await listCredentialJournals(root); const journals = await listCredentialJournals(root);
const source = await readFile(journals[0]?.path ?? '', 'utf8'); const source = await readFile(journals[0]?.path ?? '', 'utf8');
expect(source).not.toContain('seeded-secret-canary'); expect(source).not.toContain('seeded-secret-canary');
await journal.closeIncomplete();
}); });
it('supersedes a false sealed classification without editing the original journal', async (): Promise<void> => { it('supersedes a false sealed classification without editing the original journal', async (): Promise<void> => {
@@ -20,6 +20,7 @@ const SAFE_ENDPOINT = /^(?:GET|PUT|POST|DELETE) \/[A-Za-z0-9_./{}:-]+$/;
const SAFE_CONTENT_TYPE = /^[A-Za-z0-9!#$&^_.+/-]+(?:;[A-Za-z0-9=._+-]+)*$/; const SAFE_CONTENT_TYPE = /^[A-Za-z0-9!#$&^_.+/-]+(?:;[A-Za-z0-9=._+-]+)*$/;
const SAFE_DECISIONS = new Set<string>([ const SAFE_DECISIONS = new Set<string>([
'provider-grant', 'provider-grant',
'permission-none',
'permission-read', 'permission-read',
'permission-write', 'permission-write',
'permission-admin', 'permission-admin',
@@ -29,10 +30,27 @@ const SAFE_DECISIONS = new Set<string>([
'revoke-verified', 'revoke-verified',
'rotate-verified', 'rotate-verified',
'validation-requested', 'validation-requested',
'provision-requested',
'rotate-requested',
'revoke-requested',
'wire-requested',
'get-requested',
'validation-verified', 'validation-verified',
'team-member-present', 'team-member-present',
'team-repository-present', 'team-repository-present',
'team-repository-set-verified',
'organization-member-present', 'organization-member-present',
'organization-member-absent',
'collaborator-grant-applied',
'team-member-applied',
'team-repository-applied',
'transport-write-verified',
'token-mint-applied',
'token-binding-stored',
'tea-login-stored',
'token-revoke-applied',
'wire-applied',
'credential-issued',
'classification-correction', 'classification-correction',
]); ]);
@@ -159,6 +177,16 @@ export class CredentialAuditJournal {
await this.append({ phase: 'provider-evidence', at: this.now(), evidence }); await this.append({ phase: 'provider-evidence', at: this.now(), evidence });
} }
async recordMutation(decision: string): Promise<void> {
if (!SAFE_DECISIONS.has(decision)) {
throw new CredentialJournalError(
'unsafe-audit-value',
'mutation decision is outside the non-secret allowlist',
);
}
await this.append({ phase: 'mutation', at: this.now(), decision });
}
async recordCorrection(correction: CredentialJournalCorrectionDto): Promise<void> { async recordCorrection(correction: CredentialJournalCorrectionDto): Promise<void> {
if ( if (
!SAFE_NAME.test(correction.supersedesJournalId) || !SAFE_NAME.test(correction.supersedesJournalId) ||
@@ -0,0 +1,10 @@
export interface CredentialBindingMetadataDto {
readonly schemaVersion?: 1;
readonly identity: string;
readonly estate: string;
readonly host: string;
readonly providerLogin: string;
readonly tokenName: string;
readonly scopes: readonly string[];
readonly createdAt: string;
}
@@ -1,6 +1,6 @@
export type CredentialOutcome = 'ok' | 'refused' | 'error' | 'indeterminate'; export type CredentialOutcome = 'ok' | 'refused' | 'error' | 'indeterminate';
export type CredentialMutationState = 'none' | 'not-started' | 'applied' | 'unknown'; export type CredentialMutationState = 'none' | 'not-started' | 'applied' | 'unknown';
export type RepositoryPermission = 'read' | 'write' | 'admin'; export type RepositoryPermission = 'none' | 'read' | 'write' | 'admin';
export type ReceivePackState = 'advertised' | 'refused'; export type ReceivePackState = 'advertised' | 'refused';
export interface CredentialReasonDto { export interface CredentialReasonDto {
@@ -16,6 +16,7 @@ export interface CredentialValidationServiceOptions {
} }
function permissionDecision(permission: RepositoryPermission): string { function permissionDecision(permission: RepositoryPermission): string {
if (permission === 'none') return 'permission-none';
if (permission === 'admin') return 'permission-admin'; if (permission === 'admin') return 'permission-admin';
if (permission === 'write') return 'permission-write'; if (permission === 'write') return 'permission-write';
return 'permission-read'; return 'permission-read';
@@ -1,6 +1,5 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { fstatSync } from 'node:fs'; import { createReadStream, fstatSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { z } from 'zod'; import { z } from 'zod';
import type { ResolvedCredential } from './credential-provider.dto.js'; import type { ResolvedCredential } from './credential-provider.dto.js';
@@ -27,6 +26,31 @@ export class DelegatedCredentialError extends Error {
} }
} }
async function readProtectedFd(fd: number): Promise<Buffer> {
const controller = new AbortController();
const timeout = setTimeout((): void => controller.abort(), 5_000);
const chunks: Buffer[] = [];
let total = 0;
try {
const stream = createReadStream(`/proc/self/fd/${fd}`, {
highWaterMark: 4 * 1024,
signal: controller.signal,
});
for await (const chunk of stream) {
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
total += bytes.byteLength;
if (total > 32 * 1024) {
stream.destroy();
throw new Error('protected credential payload exceeded the bound');
}
chunks.push(bytes);
}
return Buffer.concat(chunks, total);
} finally {
clearTimeout(timeout);
}
}
export async function readDelegatedCredentialFromFd( export async function readDelegatedCredentialFromFd(
fd: number, fd: number,
expectedIdentity: string, expectedIdentity: string,
@@ -46,7 +70,7 @@ export async function readDelegatedCredentialFromFd(
if (currentUid === undefined || stat.uid !== currentUid || (stat.mode & 0o077) !== 0) { if (currentUid === undefined || stat.uid !== currentUid || (stat.mode & 0o077) !== 0) {
throw new Error('fd owner or permissions are unsafe'); throw new Error('fd owner or permissions are unsafe');
} }
bytes = await readFile(`/proc/self/fd/${fd}`); bytes = await readProtectedFd(fd);
} catch { } catch {
throw new DelegatedCredentialError( throw new DelegatedCredentialError(
'delegated-authority-unavailable', 'delegated-authority-unavailable',
@@ -5,7 +5,7 @@ import { mkdtemp } from 'node:fs/promises';
import { afterEach, describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it } from 'vitest';
import { rm } from 'node:fs/promises'; import { rm } from 'node:fs/promises';
import { parseCredentialEstateRegistry } from './estate-registry.js'; import { parseCredentialEstateRegistry } from './estate-registry.js';
import { FileCredentialResolver } from './file-credential-store.js'; import { FileCredentialResolver, FileCredentialStore } from './file-credential-store.js';
let cleanup: string | undefined; let cleanup: string | undefined;
@@ -56,6 +56,19 @@ describe('phase-1 governed file credential resolver', (): void => {
expect(wrongEstate).toBeUndefined(); expect(wrongEstate).toBeUndefined();
}); });
it('rejects a group-writable token directory even when the token file is private', async (): Promise<void> => {
const root = await fixtureRoot();
await writeFile(join(root, 'gitea-example-seat-name.token'), 'private-token', {
mode: 0o600,
});
await chmod(root, 0o770);
const resolver = new FileCredentialResolver(root, registry());
await expect(resolver.resolve('seat-name', 'homelab', 'git.example.invalid')).rejects.toThrow(
/insecure-token-owner/,
);
});
it('rejects a token file with group or other permissions', async (): Promise<void> => { it('rejects a token file with group or other permissions', async (): Promise<void> => {
const root = await fixtureRoot(); const root = await fixtureRoot();
const path = join(root, 'gitea-example-seat.token'); const path = join(root, 'gitea-example-seat.token');
@@ -89,6 +102,40 @@ describe('phase-1 governed file credential resolver', (): void => {
); );
}); });
it('atomically stores, lists, reads binding metadata, and removes a governed credential', async (): Promise<void> => {
const root = await fixtureRoot();
const store = new FileCredentialStore(root, registry());
await store.put(
{
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
providerLogin: 'seat',
tokenName: 'mosaic-seat-1',
scopes: ['write:repository'],
createdAt: '2026-08-05T00:00:00.000Z',
},
new TextEncoder().encode('new-private-token'),
);
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual(['seat']);
await expect(
store.readBinding('seat', 'homelab', 'git.example.invalid'),
).resolves.toMatchObject({
providerLogin: 'seat',
tokenName: 'mosaic-seat-1',
});
await expect(
new FileCredentialResolver(root, registry()).resolve(
'seat',
'homelab',
'git.example.invalid',
),
).resolves.toMatchObject({ identity: 'seat' });
await store.remove('seat', 'homelab', 'git.example.invalid');
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]);
});
it('returns undefined for an absent token without borrowing another identity', async (): Promise<void> => { it('returns undefined for an absent token without borrowing another identity', async (): Promise<void> => {
const root = await fixtureRoot(); const root = await fixtureRoot();
await writeFile(join(root, 'gitea-example-shared.token'), 'shared-canary', { mode: 0o600 }); await writeFile(join(root, 'gitea-example-shared.token'), 'shared-canary', { mode: 0o600 });
@@ -1,11 +1,31 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { lstatSync } from 'node:fs';
import { open, readdir, rename, unlink } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
import { readRegularFileSecure, type SecureFileSnapshot } from '../fleet/secure-file.js'; import { z } from 'zod';
import {
ensureManagedDirectory,
readRegularFileSecure,
type SecureFileSnapshot,
} from '../fleet/secure-file.js';
import type { CredentialBindingMetadataDto } from './credential-binding.dto.js';
import type { CredentialResolver, ResolvedCredential } from './credential-provider.dto.js'; import type { CredentialResolver, ResolvedCredential } from './credential-provider.dto.js';
import type { ParsedCredentialEstateRegistry } from './estate-registry.js'; import type { ParsedCredentialEstateRegistry } from './estate-registry.js';
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
const MAX_TOKEN_BYTES = 16 * 1024; const MAX_TOKEN_BYTES = 16 * 1024;
const bindingSchema = z
.object({
schemaVersion: z.literal(1),
identity: z.string().regex(IDENTITY),
estate: z.string().min(1),
host: z.string().min(1),
providerLogin: z.string().regex(IDENTITY),
tokenName: z.string().regex(IDENTITY),
scopes: z.array(z.string().regex(/^[a-z]+(?::[a-z]+)?$/)).max(32),
createdAt: z.string().datetime(),
})
.strict();
export class CredentialStoreError extends Error { export class CredentialStoreError extends Error {
constructor( constructor(
@@ -61,6 +81,23 @@ export class FileCredentialResolver implements CredentialResolver {
const hostConfig = this.estateRegistry.resolve(estate, host); const hostConfig = this.estateRegistry.resolve(estate, host);
if (hostConfig === undefined) return undefined; if (hostConfig === undefined) return undefined;
const currentUid = process.getuid?.();
if (currentUid === undefined) {
throw new CredentialStoreError('insecure-token-owner', 'runtime uid is unavailable');
}
const directory = lstatSync(this.tokenDirectory);
if (
!directory.isDirectory() ||
directory.isSymbolicLink() ||
directory.uid !== currentUid ||
(directory.mode & 0o022) !== 0
) {
throw new CredentialStoreError(
'insecure-token-owner',
'token directory ownership or write permissions are unsafe',
);
}
const path = join(this.tokenDirectory, `${hostConfig.tokenPrefix}-${identity}.token`); const path = join(this.tokenDirectory, `${hostConfig.tokenPrefix}-${identity}.token`);
let snapshot: SecureFileSnapshot; let snapshot: SecureFileSnapshot;
try { try {
@@ -74,6 +111,12 @@ export class FileCredentialResolver implements CredentialResolver {
} }
const permissions = snapshot.mode & 0o777; const permissions = snapshot.mode & 0o777;
if (snapshot.uid !== currentUid) {
throw new CredentialStoreError(
'insecure-token-owner',
'token file is not owned by the runtime uid',
);
}
if ((permissions & 0o077) !== 0) { if ((permissions & 0o077) !== 0) {
throw new CredentialStoreError( throw new CredentialStoreError(
'insecure-token-mode', 'insecure-token-mode',
@@ -90,3 +133,122 @@ export class FileCredentialResolver implements CredentialResolver {
}); });
} }
} }
async function syncDirectory(path: string): Promise<void> {
const handle = await open(path, 'r');
try {
await handle.sync();
} finally {
await handle.close();
}
}
export class FileCredentialStore {
constructor(
private readonly tokenDirectory: string,
private readonly estateRegistry: ParsedCredentialEstateRegistry,
) {}
private paths(
identity: string,
estate: string,
host: string,
): {
readonly token: string;
readonly binding: string;
readonly prefix: string;
} {
if (!IDENTITY.test(identity)) {
throw new CredentialStoreError(
'invalid-identity',
'identity is outside the allowlist grammar',
);
}
const config = this.estateRegistry.resolve(estate, host);
if (config === undefined) {
throw new CredentialStoreError('estate-host-mismatch', 'estate and host do not match');
}
const prefix = `${config.tokenPrefix}-${identity}`;
return {
token: join(this.tokenDirectory, `${prefix}.token`),
binding: join(this.tokenDirectory, `${prefix}.binding.json`),
prefix,
};
}
async put(metadata: CredentialBindingMetadataDto, secret: Uint8Array): Promise<void> {
const paths = this.paths(metadata.identity, metadata.estate, metadata.host);
ensureManagedDirectory(this.tokenDirectory, this.tokenDirectory);
const token = validateSecret(Buffer.from(secret));
const binding = bindingSchema.parse({ ...metadata, schemaVersion: 1 });
const suffix = randomUUID();
const tokenTemp = `${paths.token}.${suffix}.tmp`;
const bindingTemp = `${paths.binding}.${suffix}.tmp`;
const tokenHandle = await open(tokenTemp, 'wx', 0o600);
const bindingHandle = await open(bindingTemp, 'wx', 0o600);
try {
await tokenHandle.writeFile(token);
await tokenHandle.sync();
await bindingHandle.writeFile(`${JSON.stringify(binding)}\n`, 'utf8');
await bindingHandle.sync();
} finally {
await tokenHandle.close();
await bindingHandle.close();
}
await rename(bindingTemp, paths.binding);
await rename(tokenTemp, paths.token);
await syncDirectory(this.tokenDirectory);
}
async readBinding(
identity: string,
estate: string,
host: string,
): Promise<CredentialBindingMetadataDto | undefined> {
const paths = this.paths(identity, estate, host);
let snapshot: SecureFileSnapshot;
try {
snapshot = readRegularFileSecure(paths.binding, {
root: this.tokenDirectory,
maxBytes: 64 * 1024,
});
} catch (error: unknown) {
if (isMissingFile(error)) return undefined;
throw error;
}
if ((snapshot.mode & 0o077) !== 0 || snapshot.uid !== process.getuid?.()) {
throw new CredentialStoreError('insecure-token-owner', 'binding metadata is not private');
}
const parsed = bindingSchema.safeParse(JSON.parse(snapshot.content.toString('utf8')));
if (!parsed.success) {
throw new CredentialStoreError(
'invalid-binding',
'binding metadata failed schema validation',
);
}
return parsed.data;
}
async list(estate: string, host: string): Promise<readonly string[]> {
const config = this.estateRegistry.resolve(estate, host);
if (config === undefined) return [];
const names = await readdir(this.tokenDirectory);
const prefix = `${config.tokenPrefix}-`;
return names
.filter((name): boolean => name.startsWith(prefix) && name.endsWith('.token'))
.map((name): string => name.slice(prefix.length, -'.token'.length))
.filter((identity): boolean => IDENTITY.test(identity))
.sort();
}
async remove(identity: string, estate: string, host: string): Promise<void> {
const paths = this.paths(identity, estate, host);
await unlink(paths.token).catch((error: unknown): void => {
if (!isMissingFile(error)) throw error;
});
await unlink(paths.binding).catch((error: unknown): void => {
if (!isMissingFile(error)) throw error;
});
await syncDirectory(this.tokenDirectory);
}
}
@@ -195,7 +195,11 @@ describe('Gitea credential provider transport', (): void => {
return jsonResponse({ id: 21, login: 'seat-name' }); return jsonResponse({ id: 21, login: 'seat-name' });
} }
if (url.includes('/repos/owner/repo')) { if (url.includes('/repos/owner/repo')) {
return jsonResponse({ id: 4, full_name: 'owner/repo' }); return jsonResponse({
id: 4,
full_name: 'owner/repo',
permissions: { admin: false, push: true, pull: true },
});
} }
return jsonResponse({ message: 'unexpected' }, 500); return jsonResponse({ message: 'unexpected' }, 500);
}, },
@@ -213,6 +217,34 @@ describe('Gitea credential provider transport', (): void => {
expect(team).toMatchObject({ id: 7, name: 'writers', permission: 'write' }); expect(team).toMatchObject({ id: 7, name: 'writers', permission: 'write' });
}); });
it('rejects a successful team read-back that names the wrong object', async (): Promise<void> => {
const adapter = new GiteaTeamGrantProviderAdapter(
'https://git.example.invalid',
async (): Promise<Response> => jsonResponse({ id: 99, login: 'other-seat' }),
);
await expect(adapter.readTeamMember(credential, 7, 'seat-name')).rejects.toMatchObject({
code: 'unexpected-provider-shape',
});
});
it('bounds a provider that never returns response headers', async (): Promise<void> => {
const adapter = new GiteaCredentialProviderAdapter(
'https://git.example.invalid',
async (_input: string | URL | Request, init?: RequestInit): Promise<Response> =>
new Promise<Response>((_resolve, reject): void => {
init?.signal?.addEventListener('abort', (): void => {
reject(new Error('aborted'));
});
}),
10,
);
await expect(adapter.readIdentity(credential)).rejects.toMatchObject({
code: 'provider-unavailable',
});
});
it('never includes seeded secret material in provider error messages', async (): Promise<void> => { it('never includes seeded secret material in provider error messages', async (): Promise<void> => {
const adapter = new GiteaCredentialProviderAdapter( const adapter = new GiteaCredentialProviderAdapter(
'https://git.example.invalid', 'https://git.example.invalid',
@@ -1,6 +1,8 @@
import { z } from 'zod'; import { z } from 'zod';
import type { GiteaCredentialProvider, ResolvedCredential } from './credential-provider.dto.js'; import type { GiteaCredentialProvider, ResolvedCredential } from './credential-provider.dto.js';
import type { GiteaGrantProvider } from './grant.js'; import type { GiteaGrantProvider } from './grant.js';
import type { GiteaLifecycleProvider, MintedToken } from './lifecycle.js';
import type { TokenObjectEvidenceDto } from './lifecycle.dto.js';
import type { import type {
GiteaTeamGrantProvider, GiteaTeamGrantProvider,
PresenceEvidence, PresenceEvidence,
@@ -43,6 +45,14 @@ const collaboratorPermissionSchema = z
.passthrough(); .passthrough();
const organizationSchema = z.object({ username: z.string().min(1) }).passthrough(); const organizationSchema = z.object({ username: z.string().min(1) }).passthrough();
const tokenObjectSchema = z
.object({
name: z.string().min(1),
sha1: z.string().min(1).optional(),
token: z.string().min(1).optional(),
scopes: z.array(z.string()).default([]),
})
.passthrough();
const teamSchema = z const teamSchema = z
.object({ .object({
id: z.number().int().positive(), id: z.number().int().positive(),
@@ -202,7 +212,8 @@ function effectivePermission(permissions: {
}): RepositoryPermission { }): RepositoryPermission {
if (permissions.admin) return 'admin'; if (permissions.admin) return 'admin';
if (permissions.push) return 'write'; if (permissions.push) return 'write';
return 'read'; if (permissions.pull) return 'read';
return 'none';
} }
export class GiteaCredentialProviderAdapter implements GiteaCredentialProvider { export class GiteaCredentialProviderAdapter implements GiteaCredentialProvider {
@@ -211,14 +222,27 @@ export class GiteaCredentialProviderAdapter implements GiteaCredentialProvider {
constructor( constructor(
apiBaseUrl: string, apiBaseUrl: string,
private readonly fetchImpl: FetchLike = fetch, private readonly fetchImpl: FetchLike = fetch,
private readonly requestTimeoutMs = 10_000,
) { ) {
const parsed = new URL(apiBaseUrl); const parsed = new URL(apiBaseUrl);
this.origin = parsed.origin; this.origin = parsed.origin;
if (
!Number.isSafeInteger(requestTimeoutMs) ||
requestTimeoutMs < 1 ||
requestTimeoutMs > 30_000
) {
throw new CredentialProviderEvidenceError(
'invalid-input',
'provider request timeout is outside the bounded range',
);
}
} }
protected async request(url: string, init: RequestInit): Promise<Response> { protected async request(url: string, init: RequestInit): Promise<Response> {
const deadline = AbortSignal.timeout(this.requestTimeoutMs);
const signal = init.signal == null ? deadline : AbortSignal.any([init.signal, deadline]);
try { try {
return await this.fetchImpl(url, init); return await this.fetchImpl(url, { ...init, signal });
} catch { } catch {
throw new CredentialProviderEvidenceError( throw new CredentialProviderEvidenceError(
'provider-unavailable', 'provider-unavailable',
@@ -610,6 +634,10 @@ export class GiteaTeamGrantProviderAdapter
return this.readPresence( return this.readPresence(
authority, authority,
`GET /api/v1/teams/${teamId.toString()}/members/${encodeURIComponent(identity)}`, `GET /api/v1/teams/${teamId.toString()}/members/${encodeURIComponent(identity)}`,
(value: unknown): boolean => {
const parsed = userSchema.safeParse(value);
return parsed.success && parsed.data.login === identity;
},
); );
} }
@@ -622,12 +650,17 @@ export class GiteaTeamGrantProviderAdapter
return this.readPresence( return this.readPresence(
authority, authority,
`GET /api/v1/teams/${teamId.toString()}/repos/${owner}/${name}`, `GET /api/v1/teams/${teamId.toString()}/repos/${owner}/${name}`,
(value: unknown): boolean => {
const parsed = repoSchema.safeParse(value);
return parsed.success && parsed.data.full_name === repo;
},
); );
} }
private async readPresence( private async readPresence(
authority: ResolvedCredential, authority: ResolvedCredential,
endpoint: string, endpoint: string,
matchesExpectedObject: (value: unknown) => boolean,
): Promise<PresenceEvidence> { ): Promise<PresenceEvidence> {
const response = await this.request(`${this.origin}${endpoint.slice(4)}`, { const response = await this.request(`${this.origin}${endpoint.slice(4)}`, {
method: 'GET', method: 'GET',
@@ -645,7 +678,155 @@ export class GiteaTeamGrantProviderAdapter
await boundedBody(response); await boundedBody(response);
throw new CredentialProviderEvidenceError('readback-missing', 'team read-back failed'); throw new CredentialProviderEvidenceError('readback-missing', 'team read-back failed');
} }
await boundedBody(response); if (!matchesExpectedObject(await jsonObject(response))) {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'team read-back did not identify the requested object',
);
}
return { state: 'present', endpoint, contentType: contentType(response) }; return { state: 'present', endpoint, contentType: contentType(response) };
} }
} }
function basicAuthorization(authority: ResolvedCredential): string {
const prefix = Buffer.from(`${authority.identity}:`, 'utf8');
const material = Buffer.concat([prefix, Buffer.from(authority.secret)]);
try {
return `Basic ${material.toString('base64')}`;
} finally {
prefix.fill(0);
material.fill(0);
}
}
export class GiteaLifecycleProviderAdapter
extends GiteaCredentialProviderAdapter
implements GiteaLifecycleProvider
{
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 mintToken(
authority: ResolvedCredential,
identity: string,
name: string,
scopes: readonly string[],
): Promise<MintedToken> {
const endpoint = `POST /api/v1/users/${identity}/tokens`;
const response = await this.request(
`${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens`,
{
method: 'POST',
headers: {
Accept: JSON_CONTENT_TYPE,
'Content-Type': JSON_CONTENT_TYPE,
Authorization: basicAuthorization(authority),
'User-Agent': USER_AGENT,
},
body: JSON.stringify({ name, scopes }),
},
);
if (!response.ok) {
await boundedBody(response);
throw new CredentialProviderEvidenceError('provider-unavailable', 'token mint failed');
}
const parsed = tokenObjectSchema.safeParse(await jsonObject(response));
const secret = parsed.success ? (parsed.data.sha1 ?? parsed.data.token) : undefined;
if (!parsed.success || secret === undefined || parsed.data.name !== name) {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'minted token object lacked the requested name or secret',
);
}
return {
secret: new TextEncoder().encode(secret),
evidence: {
name: parsed.data.name,
scopes: parsed.data.scopes,
endpoint,
contentType: contentType(response),
},
};
}
async readToken(
authority: ResolvedCredential,
identity: string,
name: string,
): Promise<TokenObjectEvidenceDto> {
const endpoint = `GET /api/v1/users/${identity}/tokens`;
const response = await this.request(
`${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens`,
{
method: 'GET',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: basicAuthorization(authority),
'User-Agent': USER_AGENT,
},
},
);
if (!response.ok) {
await boundedBody(response);
throw new CredentialProviderEvidenceError('readback-missing', 'token list read-back failed');
}
const parsed = z.array(tokenObjectSchema).safeParse(await jsonObject(response));
const matches = parsed.success
? parsed.data.filter((token): boolean => token.name === name)
: [];
if (matches.length !== 1 || matches[0] === undefined) {
throw new CredentialProviderEvidenceError(
'readback-missing',
'minted token did not resolve uniquely by name',
);
}
return {
name: matches[0].name,
scopes: matches[0].scopes,
endpoint,
contentType: contentType(response),
};
}
async revokeToken(authority: ResolvedCredential, identity: string, name: string): Promise<void> {
const response = await this.request(
`${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens/${encodeURIComponent(name)}`,
{
method: 'DELETE',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: basicAuthorization(authority),
'User-Agent': USER_AGENT,
},
},
);
await boundedBody(response);
if (!response.ok) {
throw new CredentialProviderEvidenceError('mutation-state-unknown', 'token revoke failed');
}
}
}
@@ -1,4 +1,4 @@
import { mkdtemp, rm } from 'node:fs/promises'; import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it } from 'vitest';
@@ -130,6 +130,13 @@ describe('direct repository grant', (): void => {
expect(result.evidence.repositoryPermission?.effective).toBe('write'); expect(result.evidence.repositoryPermission?.effective).toBe('write');
expect(result.evidence.organizationMembership?.state).toBe('absent'); expect(result.evidence.organizationMembership?.state).toBe('absent');
expect(result.audit.state).toBe('sealed'); expect(result.audit.state).toBe('sealed');
const [sealed] = await listCredentialJournals(root);
const source = await readFile(sealed?.path ?? '', 'utf8');
expect(source).toContain('"phase":"mutation"');
expect(source).toContain('"decision":"collaborator-grant-applied"');
expect(source).toContain('"decision":"identity-verified"');
expect(source).toContain('"decision":"organization-member-absent"');
expect(source).toContain('"decision":"transport-write-verified"');
}); });
it('preserves applied mutation and journal context when post-grant read-back fails', async (): Promise<void> => { it('preserves applied mutation and journal context when post-grant read-back fails', async (): Promise<void> => {
+34
View File
@@ -106,6 +106,11 @@ export async function grantDirectRepositoryPermission(
audit: { journalId: journal.journalId(), state: 'sealed' }, audit: { journalId: journal.journalId(), state: 'sealed' },
}; };
} }
await journal.recordProviderEvidence({
endpoint: authorityIdentity.endpoint,
contentType: authorityIdentity.contentType,
decision: 'identity-verified',
});
mutation = 'unknown'; mutation = 'unknown';
await grantProvider.grantCollaborator( await grantProvider.grantCollaborator(
authority, authority,
@@ -114,6 +119,7 @@ export async function grantDirectRepositoryPermission(
request.permission, request.permission,
); );
mutation = 'applied'; mutation = 'applied';
await journal.recordMutation('collaborator-grant-applied');
const collaborator = await grantProvider.readCollaboratorPermission( const collaborator = await grantProvider.readCollaboratorPermission(
authority, authority,
@@ -135,6 +141,34 @@ export async function grantDirectRepositoryPermission(
? await evaluateGiteaReadValidation(request, validationDependencies) ? await evaluateGiteaReadValidation(request, validationDependencies)
: await evaluateGiteaWriteValidation(request, validationDependencies); : await evaluateGiteaWriteValidation(request, validationDependencies);
if (organizationMembership !== null) {
await journal.recordProviderEvidence({
endpoint: organizationMembership.endpoint,
contentType: organizationMembership.contentType,
decision:
organizationMembership.state === 'present'
? 'organization-member-present'
: 'organization-member-absent',
});
}
if (validation.evidence.providerIdentity !== null) {
await journal.recordProviderEvidence({
endpoint: validation.evidence.providerIdentity.endpoint,
contentType: validation.evidence.providerIdentity.contentType,
decision: 'identity-verified',
});
}
if (validation.evidence.repositoryPermission !== null) {
await journal.recordProviderEvidence({
endpoint: validation.evidence.repositoryPermission.endpoint,
contentType: validation.evidence.repositoryPermission.contentType,
decision: `permission-${validation.evidence.repositoryPermission.effective}`,
});
}
if (validation.evidence.writeDifferential !== null) {
await journal.recordMutation('transport-write-verified');
}
const readBackMatches = const readBackMatches =
collaborator.identity === request.identity && collaborator.identity === request.identity &&
collaborator.permission === request.permission && collaborator.permission === request.permission &&
@@ -0,0 +1,49 @@
import type { CredentialOutcome } from './credential-result.dto.js';
export type CredentialLifecycleOperation =
| 'provision'
| 'wire'
| 'get'
| 'whoami'
| 'list'
| 'rotate'
| 'revoke'
| 'audit';
export interface TokenObjectEvidenceDto {
readonly name: string;
readonly scopes: readonly string[];
readonly endpoint: string;
readonly contentType: string;
}
export interface CredentialLifecycleResultDto {
readonly schemaVersion: 1;
readonly operation: CredentialLifecycleOperation;
readonly outcome: CredentialOutcome;
readonly exitCode: 0 | 10 | 20 | 30;
readonly retryable: boolean;
readonly subject: {
readonly identity: string;
readonly estate: string;
readonly host: string;
readonly repo: null;
};
readonly mutation: 'none' | 'unknown' | 'applied';
readonly reason: { readonly code: string; readonly message: string };
readonly evidence: {
readonly providerIdentity: string | null;
readonly token: TokenObjectEvidenceDto | null;
readonly teaLogin: {
readonly name: string;
readonly host: string;
readonly state: 'registered' | 'not-measured';
} | null;
readonly identities: readonly string[];
readonly journalIds: readonly string[];
};
readonly audit: {
readonly journalId: string | null;
readonly state: 'not-started' | 'open' | 'sealed';
};
}
@@ -0,0 +1,143 @@
import { mkdtemp, mkdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import type { ResolvedCredential } from './credential-provider.dto.js';
import { parseCredentialEstateRegistry } from './estate-registry.js';
import { FileCredentialStore } from './file-credential-store.js';
import { provisionCredential, revokeCredential, type GiteaLifecycleProvider } from './lifecycle.js';
import { TeaLoginStore } from './tea-login-store.js';
let cleanup: string | undefined;
afterEach(async (): Promise<void> => {
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
});
async function fixture(): Promise<{
root: string;
store: FileCredentialStore;
teaStore: TeaLoginStore;
}> {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-lifecycle-'));
const tokens = join(cleanup, 'tokens');
await mkdir(tokens, { mode: 0o700 });
const registry = parseCredentialEstateRegistry(
JSON.stringify({
version: 1,
estates: [
{
name: 'homelab',
readOnlyControlIdentity: 'control',
hosts: [
{
host: 'git.example.invalid',
provider: 'gitea',
apiBaseUrl: 'https://git.example.invalid',
tokenPrefix: 'gitea-example',
},
],
},
],
}),
);
return {
root: join(cleanup, 'state'),
store: new FileCredentialStore(tokens, registry),
teaStore: new TeaLoginStore(join(cleanup, 'tea', 'config.yml')),
};
}
const authority: ResolvedCredential = Object.freeze({
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
resolutionId: 'basic-authority',
secret: new TextEncoder().encode('password-canary'),
});
function provider(): GiteaLifecycleProvider {
return {
async readBasicIdentity() {
return { login: 'seat', endpoint: 'GET /api/v1/user', contentType: 'application/json' };
},
async mintToken(_authority, _identity, name, scopes) {
return {
secret: new TextEncoder().encode('minted-token-canary'),
evidence: {
name,
scopes,
endpoint: 'POST /api/v1/users/seat/tokens',
contentType: 'application/json',
},
};
},
async readToken(_authority, _identity, name) {
return {
name,
scopes: ['write:repository'],
endpoint: 'GET /api/v1/users/seat/tokens',
contentType: 'application/json',
};
},
async revokeToken(): Promise<void> {},
};
}
describe('credential lifecycle', (): void => {
it('accepts provision only after exact principal and scope read-back', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
const result = await provisionCredential(
{
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
tokenName: 'mosaic-seat-1',
scopes: ['write:repository'],
},
authority,
provider(),
store,
teaStore,
{ stateRoot: root, actor: 'seat', now: (): string => '2026-08-05T00:00:00.000Z' },
);
expect(result.outcome).toBe('ok');
await expect(
store.readBinding('seat', 'homelab', 'git.example.invalid'),
).resolves.toMatchObject({ providerLogin: 'seat', scopes: ['write:repository'] });
expect(JSON.stringify(result)).not.toContain('minted-token-canary');
});
it('revokes at provider before removing the local binding', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
await provisionCredential(
{
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
tokenName: 'mosaic-seat-1',
scopes: ['write:repository'],
},
authority,
provider(),
store,
teaStore,
{ stateRoot: root, actor: 'seat' },
);
let revoked = false;
const lifecycleProvider = provider();
lifecycleProvider.revokeToken = async (): Promise<void> => {
revoked = true;
};
const result = await revokeCredential(
{ identity: 'seat', estate: 'homelab', host: 'git.example.invalid' },
authority,
lifecycleProvider,
store,
{ stateRoot: root, actor: 'seat' },
);
expect(result.outcome).toBe('ok');
expect(revoked).toBe(true);
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]);
});
});
@@ -0,0 +1,272 @@
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
import type { ResolvedCredential } from './credential-provider.dto.js';
import type { FileCredentialStore } from './file-credential-store.js';
import type { TeaLoginStore } from './tea-login-store.js';
import type {
CredentialLifecycleOperation,
CredentialLifecycleResultDto,
TokenObjectEvidenceDto,
} from './lifecycle.dto.js';
export interface MintedToken {
readonly secret: Uint8Array;
readonly evidence: TokenObjectEvidenceDto;
}
export interface GiteaLifecycleProvider {
readBasicIdentity(authority: ResolvedCredential): Promise<{
readonly login: string;
readonly endpoint: string;
readonly contentType: string;
}>;
mintToken(
authority: ResolvedCredential,
identity: string,
name: string,
scopes: readonly string[],
): Promise<MintedToken>;
readToken(
authority: ResolvedCredential,
identity: string,
name: string,
): Promise<TokenObjectEvidenceDto>;
revokeToken(authority: ResolvedCredential, identity: string, name: string): Promise<void>;
}
export interface LifecycleRequest {
readonly identity: string;
readonly estate: string;
readonly host: string;
}
export interface ProvisionRequest extends LifecycleRequest {
readonly tokenName: string;
readonly scopes: readonly string[];
}
export interface LifecycleOptions {
readonly stateRoot: string;
readonly actor: string;
readonly now?: () => string;
}
function lifecycleResult(
operation: CredentialLifecycleOperation,
request: LifecycleRequest,
options: {
readonly outcome: CredentialLifecycleResultDto['outcome'];
readonly mutation: CredentialLifecycleResultDto['mutation'];
readonly code: string;
readonly message: string;
readonly journalId: string | null;
readonly auditState: 'not-started' | 'open' | 'sealed';
readonly providerIdentity?: string | null;
readonly token?: TokenObjectEvidenceDto | null;
readonly teaLogin?: CredentialLifecycleResultDto['evidence']['teaLogin'];
},
): CredentialLifecycleResultDto {
const exits = { ok: 0, refused: 10, error: 20, indeterminate: 30 } as const;
return {
schemaVersion: 1,
operation,
outcome: options.outcome,
exitCode: exits[options.outcome],
retryable: false,
subject: { ...request, repo: null },
mutation: options.mutation,
reason: { code: options.code, message: options.message },
evidence: {
providerIdentity: options.providerIdentity ?? null,
token: options.token ?? null,
teaLogin: options.teaLogin ?? null,
identities: [],
journalIds: [],
},
audit: { journalId: options.journalId, state: options.auditState },
};
}
async function openLifecycleJournal(
operation: 'provision' | 'rotate' | 'revoke',
request: LifecycleRequest,
options: LifecycleOptions,
): Promise<CredentialAuditJournal> {
const journal = await CredentialAuditJournal.open(options.stateRoot, {
operation,
actor: options.actor,
identity: request.identity,
estate: request.estate,
host: request.host,
repo: null,
});
await journal.recordIntent(`${operation}-requested`);
return journal;
}
export async function provisionCredential(
request: ProvisionRequest,
authority: ResolvedCredential,
provider: GiteaLifecycleProvider,
store: FileCredentialStore,
teaStore: TeaLoginStore,
options: LifecycleOptions,
): Promise<CredentialLifecycleResultDto> {
const journal = await openLifecycleJournal('provision', request, options);
let mutation: 'none' | 'unknown' | 'applied' = 'none';
let minted: MintedToken | undefined;
try {
const identity = await provider.readBasicIdentity(authority);
if (identity.login !== request.identity || authority.identity !== request.identity) {
await journal.seal('refused', 'provider-identity-mismatch');
return lifecycleResult('provision', 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';
minted = await provider.mintToken(
authority,
request.identity,
request.tokenName,
request.scopes,
);
mutation = 'applied';
await journal.recordMutation('token-mint-applied');
const readBack = await provider.readToken(authority, request.identity, request.tokenName);
const expected = [...request.scopes].sort();
const actual = [...readBack.scopes].sort();
if (JSON.stringify(expected) !== JSON.stringify(actual)) {
await journal.seal('indeterminate', 'scope-not-evaluable');
return lifecycleResult('provision', request, {
outcome: 'indeterminate',
mutation,
code: 'scope-not-evaluable',
message: 'Minted token scope read-back disagreed with the request.',
journalId: journal.journalId(),
auditState: 'sealed',
providerIdentity: identity.login,
token: readBack,
});
}
await store.put(
{
identity: request.identity,
estate: request.estate,
host: request.host,
providerLogin: identity.login,
tokenName: request.tokenName,
scopes: readBack.scopes,
createdAt: options.now?.() ?? new Date().toISOString(),
},
minted.secret,
);
await journal.recordMutation('token-binding-stored');
await teaStore.put(request.identity, request.host, minted.secret);
const teaLogin = teaStore.readBack(request.identity, request.host);
if (teaLogin === undefined) {
await journal.seal('indeterminate', 'tea-login-missing');
return lifecycleResult('provision', request, {
outcome: 'indeterminate',
mutation: 'applied',
code: 'tea-login-missing',
message: 'Token was stored but the host-bound Tea login did not read back.',
journalId: journal.journalId(),
auditState: 'sealed',
providerIdentity: identity.login,
token: readBack,
});
}
await journal.recordMutation('tea-login-stored');
await journal.recordProviderEvidence({
endpoint: readBack.endpoint,
contentType: readBack.contentType,
decision: 'scope-verified',
});
await journal.seal('ok', 'provision-verified');
return lifecycleResult('provision', request, {
outcome: 'ok',
mutation: 'applied',
code: 'provision-verified',
message: 'Provider principal and exact token scopes were read back and stored.',
journalId: journal.journalId(),
auditState: 'sealed',
providerIdentity: identity.login,
token: readBack,
teaLogin: { ...teaLogin, state: 'registered' },
});
} catch (error: unknown) {
if (error instanceof CredentialJournalError) throw error;
const code = mutation === 'none' ? 'provider-unavailable' : 'mutation-state-unknown';
await journal.seal('indeterminate', code);
return lifecycleResult('provision', request, {
outcome: 'indeterminate',
mutation,
code,
message: 'Provisioning did not produce complete provider and storage evidence.',
journalId: journal.journalId(),
auditState: 'sealed',
});
} finally {
minted?.secret.fill(0);
}
}
export async function revokeCredential(
request: LifecycleRequest,
authority: ResolvedCredential,
provider: GiteaLifecycleProvider,
store: FileCredentialStore,
options: LifecycleOptions,
): Promise<CredentialLifecycleResultDto> {
const journal = await openLifecycleJournal('revoke', request, options);
let mutation: 'none' | 'unknown' | 'applied' = 'none';
try {
const binding = await store.readBinding(request.identity, request.estate, request.host);
if (binding === undefined) {
await journal.seal('refused', 'no-token-for-identity');
return lifecycleResult('revoke', request, {
outcome: 'refused',
mutation: 'none',
code: 'no-token-for-identity',
message: 'No governed token binding exists for the identity.',
journalId: journal.journalId(),
auditState: 'sealed',
});
}
mutation = 'unknown';
await provider.revokeToken(authority, request.identity, binding.tokenName);
mutation = 'applied';
await journal.recordMutation('token-revoke-applied');
await store.remove(request.identity, request.estate, request.host);
await journal.seal('ok', 'revoke-verified');
return lifecycleResult('revoke', request, {
outcome: 'ok',
mutation,
code: 'revoke-verified',
message: 'Provider token revocation completed before local binding removal.',
journalId: journal.journalId(),
auditState: 'sealed',
});
} catch (error: unknown) {
if (error instanceof CredentialJournalError) throw error;
await journal.seal('indeterminate', 'mutation-state-unknown');
return lifecycleResult('revoke', request, {
outcome: 'indeterminate',
mutation,
code: 'mutation-state-unknown',
message: 'Revocation mutation state could not be established completely.',
journalId: journal.journalId(),
auditState: 'sealed',
});
}
}
@@ -0,0 +1,127 @@
import { randomUUID } from 'node:crypto';
import { open, rename } from 'node:fs/promises';
import { dirname } from 'node:path';
import { parse, stringify } from 'yaml';
import { z } from 'zod';
import { ensureManagedDirectory, readRegularFileSecure } from '../fleet/secure-file.js';
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
export class TeaLoginStoreError extends Error {
constructor(
public readonly code: string,
message: string,
) {
super(`Tea login store rejected: code=${code} ${message}`);
this.name = 'TeaLoginStoreError';
}
}
interface TeaLoginRecord {
readonly name: string;
readonly url: string;
readonly token: string;
readonly user: string;
readonly default: boolean;
}
interface TeaConfig {
readonly logins: TeaLoginRecord[];
}
const loginSchema = z
.object({
name: z.string().regex(SAFE_NAME),
url: z.string().url(),
token: z.string().min(1),
user: z.string().regex(SAFE_NAME),
default: z.boolean().default(false),
})
.passthrough();
const configSchema = z.object({ logins: z.array(loginSchema).default([]) }).passthrough();
function missing(error: unknown): boolean {
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
}
export class TeaLoginStore {
constructor(private readonly configPath: string) {}
async put(identity: string, host: string, secret: Uint8Array): Promise<void> {
if (!SAFE_NAME.test(identity) || !/^[a-z0-9][a-z0-9.-]*$/.test(host)) {
throw new TeaLoginStoreError('invalid-input', 'identity or host is outside the grammar');
}
const directory = dirname(this.configPath);
ensureManagedDirectory(directory, directory);
let current: TeaConfig = { logins: [] };
try {
const snapshot = readRegularFileSecure(this.configPath, {
root: directory,
maxBytes: 1024 * 1024,
});
if ((snapshot.mode & 0o077) !== 0 || snapshot.uid !== process.getuid?.()) {
throw new TeaLoginStoreError('tea-config-insecure', 'Tea config is not private');
}
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
if (!decoded.success) {
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
}
current = { logins: decoded.data.logins };
} catch (error: unknown) {
if (!missing(error)) throw error;
}
const token = Buffer.from(secret).toString('utf8');
const record: TeaLoginRecord = {
name: identity,
url: `https://${host}`,
token,
user: identity,
default: false,
};
const logins = current.logins.filter((login): boolean => login.name !== identity);
logins.push(record);
const temp = `${this.configPath}.${randomUUID()}.tmp`;
const handle = await open(temp, 'wx', 0o600);
try {
await handle.writeFile(stringify({ ...current, logins }), 'utf8');
await handle.sync();
} finally {
await handle.close();
}
await rename(temp, this.configPath);
}
readBack(
identity: string,
host: string,
): { readonly name: string; readonly host: string } | undefined {
const directory = dirname(this.configPath);
let snapshot;
try {
snapshot = readRegularFileSecure(this.configPath, { root: directory, maxBytes: 1024 * 1024 });
} catch (error: unknown) {
if (missing(error)) return undefined;
throw error;
}
const decoded: unknown = parse(snapshot.content.toString('utf8'));
if (
typeof decoded !== 'object' ||
decoded === null ||
!('logins' in decoded) ||
!Array.isArray(decoded.logins)
)
return undefined;
const matches = decoded.logins.filter((value: unknown): value is TeaLoginRecord => {
if (typeof value !== 'object' || value === null) return false;
return (
'name' in value &&
value.name === identity &&
'url' in value &&
value.url === `https://${host}` &&
'user' in value &&
value.user === identity
);
});
return matches.length === 1 ? { name: identity, host } : undefined;
}
}
@@ -112,7 +112,22 @@ export async function grantTeamRepositoryPermission(
null, null,
); );
} }
await journal.recordProviderEvidence({
endpoint: authorityIdentity.endpoint,
contentType: authorityIdentity.contentType,
decision: 'identity-verified',
});
await journal.recordProviderEvidence({
endpoint: team.endpoint,
contentType: team.contentType,
decision: `permission-${team.permission}`,
});
const teamRepositorySet = await provider.listTeamRepositories(authority, team.id); const teamRepositorySet = await provider.listTeamRepositories(authority, team.id);
await journal.recordProviderEvidence({
endpoint: teamRepositorySet.endpoint,
contentType: teamRepositorySet.contentType,
decision: 'team-repository-set-verified',
});
if (teamRepositorySet.repositories.some((repo): boolean => repo !== request.repo)) { if (teamRepositorySet.repositories.some((repo): boolean => repo !== request.repo)) {
await journal.seal('refused', 'team-scope-exceeds-request'); await journal.seal('refused', 'team-scope-exceeds-request');
return result( return result(
@@ -132,7 +147,9 @@ export async function grantTeamRepositoryPermission(
mutation = 'unknown'; mutation = 'unknown';
await provider.addTeamMember(authority, team.id, request.identity); await provider.addTeamMember(authority, team.id, request.identity);
mutation = 'applied'; mutation = 'applied';
await journal.recordMutation('team-member-applied');
await provider.attachTeamRepository(authority, team.id, request.repo); await provider.attachTeamRepository(authority, team.id, request.repo);
await journal.recordMutation('team-repository-applied');
const teamMembership = await provider.readTeamMember(authority, team.id, request.identity); const teamMembership = await provider.readTeamMember(authority, team.id, request.identity);
const teamRepository = await provider.readTeamRepository(authority, team.id, request.repo); const teamRepository = await provider.readTeamRepository(authority, team.id, request.repo);
const subject = await dependencies.resolver.resolve( const subject = await dependencies.resolver.resolve(
@@ -148,6 +165,33 @@ export async function grantTeamRepositoryPermission(
request.permission === 'read' request.permission === 'read'
? await evaluateGiteaReadValidation(request, dependencies) ? await evaluateGiteaReadValidation(request, dependencies)
: await evaluateGiteaWriteValidation(request, dependencies); : await evaluateGiteaWriteValidation(request, dependencies);
if (organizationMembership !== null) {
await journal.recordProviderEvidence({
endpoint: organizationMembership.endpoint,
contentType: organizationMembership.contentType,
decision:
organizationMembership.state === 'present'
? 'organization-member-present'
: 'organization-member-absent',
});
}
if (validation.evidence.providerIdentity !== null) {
await journal.recordProviderEvidence({
endpoint: validation.evidence.providerIdentity.endpoint,
contentType: validation.evidence.providerIdentity.contentType,
decision: 'identity-verified',
});
}
if (validation.evidence.repositoryPermission !== null) {
await journal.recordProviderEvidence({
endpoint: validation.evidence.repositoryPermission.endpoint,
contentType: validation.evidence.repositoryPermission.contentType,
decision: `permission-${validation.evidence.repositoryPermission.effective}`,
});
}
if (validation.evidence.writeDifferential !== null) {
await journal.recordMutation('transport-write-verified');
}
const ok = const ok =
teamMembership.state === 'present' && teamMembership.state === 'present' &&
teamRepository.state === 'present' && teamRepository.state === 'present' &&
@@ -14,12 +14,12 @@ import {
interface FixtureOptions { interface FixtureOptions {
readonly subjectProviderIdentity?: string; readonly subjectProviderIdentity?: string;
readonly subjectPermission?: 'read' | 'write' | 'admin'; readonly subjectPermission?: 'none' | 'read' | 'write' | 'admin';
readonly subjectTransportState?: 'advertised' | 'refused'; readonly subjectTransportState?: 'advertised' | 'refused';
readonly subjectTransportPrincipal?: string; readonly subjectTransportPrincipal?: string;
readonly subjectTransportResolutionId?: string; readonly subjectTransportResolutionId?: string;
readonly controlProviderIdentity?: string; readonly controlProviderIdentity?: string;
readonly controlPermission?: 'read' | 'write' | 'admin'; readonly controlPermission?: 'none' | 'read' | 'write' | 'admin';
readonly controlTransportState?: 'advertised' | 'refused'; readonly controlTransportState?: 'advertised' | 'refused';
readonly controlTransportPrincipal?: string; readonly controlTransportPrincipal?: string;
readonly unauthenticatedTransportState?: 'advertised' | 'refused'; readonly unauthenticatedTransportState?: 'advertised' | 'refused';
@@ -173,6 +173,17 @@ describe('Gitea read validation', (): void => {
expect(observed.resolverCalls).toEqual([SUBJECT]); expect(observed.resolverCalls).toEqual([SUBJECT]);
}); });
it('refuses a repository object whose permission flags establish no read access', async (): Promise<void> => {
const observed = fixture({ subjectPermission: 'none' });
const result = await evaluateGiteaReadValidation(
{ identity: SUBJECT, estate: ESTATE, host: HOST, repo: REPO },
observed.dependencies,
);
expect(result.outcome).toBe('refused');
expect(result.reason.code).toBe('permission-denied');
});
it('classifies the provider rejecting the subject credential as an authoritative refusal', async (): Promise<void> => { it('classifies the provider rejecting the subject credential as an authoritative refusal', async (): Promise<void> => {
const observed = fixture({ subjectPermission: 'read' }); const observed = fixture({ subjectPermission: 'read' });
observed.dependencies.provider.readIdentity = async (): Promise<ProviderIdentityEvidence> => { observed.dependencies.provider.readIdentity = async (): Promise<ProviderIdentityEvidence> => {
@@ -226,6 +237,30 @@ describe('Gitea read validation', (): void => {
}); });
describe('principal-bound Gitea write validation contract v1.1', (): void => { describe('principal-bound Gitea write validation contract v1.1', (): void => {
it('confirms write capability when identity is scope-forbidden without exposing the internal reason', async (): Promise<void> => {
const observed = fixture();
observed.dependencies.provider.readIdentity = async (): Promise<ProviderIdentityEvidence> => {
throw new CredentialProviderEvidenceError(
'identity-read-forbidden',
'identity endpoint scope forbidden',
);
};
const result = await evaluateGiteaWriteValidation(
{
identity: SUBJECT,
estate: ESTATE,
host: HOST,
repo: REPO,
readOnlyControlIdentity: CONTROL,
},
observed.dependencies,
);
expect(result.outcome).toBe('indeterminate');
expect(result.reason.code).toBe('identity-not-measured');
expect(result.evidence.repositoryPermission?.effective).toBe('write');
expect(observed.receivePackHandles).toEqual([expect.objectContaining({ identity: SUBJECT })]);
});
it('uses one immutable subject credential handle for identity, permission, and receive-pack', async (): Promise<void> => { it('uses one immutable subject credential handle for identity, permission, and receive-pack', async (): Promise<void> => {
const { result, observed } = await validate(); const { result, observed } = await validate();
+78 -2
View File
@@ -239,6 +239,16 @@ async function evaluateGiteaReadValidationUnsafe(
evidence, evidence,
); );
} }
if (repositoryPermission.effective === 'none') {
return refused(
request,
{
code: 'permission-denied',
message: 'The in-scope provider object denies repository access.',
},
evidence,
);
}
return indeterminate( return indeterminate(
request, request,
{ {
@@ -273,6 +283,16 @@ async function evaluateGiteaReadValidationUnsafe(
evidence, evidence,
); );
} }
if (repositoryPermission.effective === 'none') {
return refused(
request,
{
code: 'permission-denied',
message: 'The provider repository object denies read permission.',
},
evidence,
);
}
if (providerIdentity.login !== request.identity) { if (providerIdentity.login !== request.identity) {
return refused( return refused(
request, request,
@@ -324,7 +344,60 @@ async function evaluateGiteaWriteValidationUnsafe(
}); });
} }
const subjectEvidence = await readSubjectEvidence(request, resolved, dependencies.provider); let subjectEvidence: Awaited<ReturnType<typeof readSubjectEvidence>>;
try {
subjectEvidence = await readSubjectEvidence(request, resolved, dependencies.provider);
} catch (error: unknown) {
if (
error instanceof CredentialProviderEvidenceError &&
error.code === 'identity-read-forbidden'
) {
const permission = await dependencies.provider.readRepositoryPermission(
resolved,
request.repo,
);
const receivePack = await dependencies.provider.probeReceivePack(resolved, request.repo);
const evidence: CredentialValidationEvidenceDto = {
providerIdentity: null,
repositoryPermission: permission,
writeDifferential: null,
};
if (permission.effective === 'none' || permission.effective === 'read') {
return refused(
request,
{
code: 'permission-denied',
message: 'The in-scope provider object denies required write capability.',
},
evidence,
);
}
if (
receivePack.principal !== request.identity ||
receivePack.resolutionId !== resolved.resolutionId ||
!advertised(receivePack)
) {
return indeterminate(
request,
{
code: 'permission-evidence-disagrees',
message: 'In-scope repository and write transport evidence did not agree.',
},
evidence,
);
}
return indeterminate(
request,
{
code: 'identity-not-measured',
message:
'Write capability was confirmed, but identity was not measured because this least-privilege token cannot read /user.',
},
evidence,
);
}
throw error;
}
const baseEvidence: CredentialValidationEvidenceDto = { const baseEvidence: CredentialValidationEvidenceDto = {
providerIdentity: subjectEvidence.identity, providerIdentity: subjectEvidence.identity,
repositoryPermission: subjectEvidence.permission, repositoryPermission: subjectEvidence.permission,
@@ -371,7 +444,10 @@ async function evaluateGiteaWriteValidationUnsafe(
baseEvidence, baseEvidence,
); );
} }
if (subjectEvidence.permission.effective === 'read') { if (
subjectEvidence.permission.effective === 'read' ||
subjectEvidence.permission.effective === 'none'
) {
return refused( return refused(
request, request,
{ {
+4
View File
@@ -22,6 +22,8 @@ export interface SecureFileSnapshot {
mode: number; mode: number;
dev: number | bigint; dev: number | bigint;
ino: number | bigint; ino: number | bigint;
uid: number;
gid: number;
} }
function sameIdentity( function sameIdentity(
@@ -235,6 +237,8 @@ export function readRegularFileSecure(
mode: Number(opened.mode), mode: Number(opened.mode),
dev: opened.dev, dev: opened.dev,
ino: opened.ino, ino: opened.ino,
uid: opened.uid,
gid: opened.gid,
}; };
} finally { } finally {
closeDescriptors(openedFile.descriptors); closeDescriptors(openedFile.descriptors);