feat(mosaic): complete credential lifecycle and fail closed
This commit is contained in:
@@ -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] PRD update.
|
||||
- [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.
|
||||
- [ ] 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;
|
||||
- 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/`.
|
||||
|
||||
@@ -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.
|
||||
- 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`.
|
||||
|
||||
@@ -534,12 +534,21 @@ get_gitea_token() {
|
||||
# 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
|
||||
# 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
|
||||
echo " Refusing to borrow another slot's token. Provision the per-slot token, or unset the identity to use shared credentials." >&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' \
|
||||
"$_ident" "$host" "$_ident_src" "$_idtok" >&2
|
||||
return 1
|
||||
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)
|
||||
if [[ -f "$cred_loader" ]]; then
|
||||
local token
|
||||
|
||||
@@ -45,8 +45,23 @@ if [ -n "$ident" ]; then
|
||||
echo "password=$(cat "$idtok")"
|
||||
exit 0
|
||||
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
|
||||
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
|
||||
git.uscllc.com) svc=gitea-usc;;
|
||||
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: 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 ->
|
||||
# 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: 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
|
||||
# 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
|
||||
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
|
||||
@@ -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"
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity agentA
|
||||
out=$(call_get_gitea_token "git.mosaicstack.dev")
|
||||
assert_eq "git-config identity token" "agentA-mosaicstack-token" "$out"
|
||||
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_AGENT_NAME=agentA)
|
||||
assert_eq "confirmed fleet identity bypasses shared path" "agentA-mosaicstack-token" "$out"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
|
||||
@@ -143,13 +159,17 @@ assert_failloud() {
|
||||
echo "$stderr" >&2
|
||||
fail=1
|
||||
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
|
||||
echo "FAIL: $desc — stderr does not name the expected per-slot token path '$expected_tok_path':" >&2
|
||||
echo "$stderr" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ "$stderr" == *"shared"*"token"* ]]; then
|
||||
echo "FAIL: $desc — stderr unexpectedly mentions a shared token value:" >&2
|
||||
if [[ "$stderr" == *"shared-mosaicstack-token"* || "$stderr" == *"shared-usc-token"* ]]; then
|
||||
echo "FAIL: $desc — stderr unexpectedly contains a shared credential value:" >&2
|
||||
echo "$stderr" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { fstatSync, writeSync } from 'node:fs';
|
||||
import { open, readFile, rename } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
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 {
|
||||
runCredentialReadValidation,
|
||||
@@ -20,6 +26,9 @@ import {
|
||||
import type { CredentialGrantResultDto } from '../credentials/grant.dto.js';
|
||||
import { grantTeamRepositoryPermission } 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 {
|
||||
CredentialEstateRegistryError,
|
||||
parseCredentialEstateRegistry,
|
||||
@@ -27,9 +36,11 @@ import {
|
||||
import {
|
||||
CredentialStoreError,
|
||||
FileCredentialResolver,
|
||||
FileCredentialStore,
|
||||
} from '../credentials/file-credential-store.js';
|
||||
import {
|
||||
GiteaCredentialProviderAdapter,
|
||||
GiteaLifecycleProviderAdapter,
|
||||
GiteaTeamGrantProviderAdapter,
|
||||
} from '../credentials/gitea-provider.js';
|
||||
|
||||
@@ -64,6 +75,23 @@ interface CredentialGrantCommandOptions {
|
||||
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 {
|
||||
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<
|
||||
CredentialValidationResultDto | CredentialGrantResultDto | TeamGrantResult,
|
||||
| CredentialValidationResultDto
|
||||
| CredentialGrantResultDto
|
||||
| TeamGrantResult
|
||||
| CredentialLifecycleResultDto,
|
||||
'operation' | 'outcome' | 'exitCode' | 'reason'
|
||||
>;
|
||||
|
||||
@@ -306,6 +740,125 @@ export function registerCredentialCommand(parent: Command): void {
|
||||
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
|
||||
.command('grant <identity>')
|
||||
.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);
|
||||
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'
|
||||
| 'validate'
|
||||
| 'rotate'
|
||||
| 'revoke';
|
||||
| 'revoke'
|
||||
| 'whoami'
|
||||
| 'list'
|
||||
| 'audit';
|
||||
|
||||
export interface CredentialJournalContextDto {
|
||||
readonly operation: CredentialJournalOperation;
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('credential durable audit journal', (): void => {
|
||||
|
||||
it('leaves an unsealed journal visible for recovery', async (): Promise<void> => {
|
||||
const root = await stateRoot();
|
||||
await CredentialAuditJournal.open(root, {
|
||||
const journal = await CredentialAuditJournal.open(root, {
|
||||
operation: 'rotate',
|
||||
actor: 'provisioner',
|
||||
identity: 'seat-name',
|
||||
@@ -64,6 +64,7 @@ describe('credential durable audit journal', (): void => {
|
||||
|
||||
expect(journals).toHaveLength(1);
|
||||
expect(journals[0]?.state).toBe('open');
|
||||
await journal.closeIncomplete();
|
||||
});
|
||||
|
||||
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 source = await readFile(journals[0]?.path ?? '', 'utf8');
|
||||
expect(source).not.toContain('seeded-secret-canary');
|
||||
await journal.closeIncomplete();
|
||||
});
|
||||
|
||||
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_DECISIONS = new Set<string>([
|
||||
'provider-grant',
|
||||
'permission-none',
|
||||
'permission-read',
|
||||
'permission-write',
|
||||
'permission-admin',
|
||||
@@ -29,10 +30,27 @@ const SAFE_DECISIONS = new Set<string>([
|
||||
'revoke-verified',
|
||||
'rotate-verified',
|
||||
'validation-requested',
|
||||
'provision-requested',
|
||||
'rotate-requested',
|
||||
'revoke-requested',
|
||||
'wire-requested',
|
||||
'get-requested',
|
||||
'validation-verified',
|
||||
'team-member-present',
|
||||
'team-repository-present',
|
||||
'team-repository-set-verified',
|
||||
'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',
|
||||
]);
|
||||
|
||||
@@ -159,6 +177,16 @@ export class CredentialAuditJournal {
|
||||
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> {
|
||||
if (
|
||||
!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 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 interface CredentialReasonDto {
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface CredentialValidationServiceOptions {
|
||||
}
|
||||
|
||||
function permissionDecision(permission: RepositoryPermission): string {
|
||||
if (permission === 'none') return 'permission-none';
|
||||
if (permission === 'admin') return 'permission-admin';
|
||||
if (permission === 'write') return 'permission-write';
|
||||
return 'permission-read';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { fstatSync } from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createReadStream, fstatSync } from 'node:fs';
|
||||
import { z } from 'zod';
|
||||
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(
|
||||
fd: number,
|
||||
expectedIdentity: string,
|
||||
@@ -46,7 +70,7 @@ export async function readDelegatedCredentialFromFd(
|
||||
if (currentUid === undefined || stat.uid !== currentUid || (stat.mode & 0o077) !== 0) {
|
||||
throw new Error('fd owner or permissions are unsafe');
|
||||
}
|
||||
bytes = await readFile(`/proc/self/fd/${fd}`);
|
||||
bytes = await readProtectedFd(fd);
|
||||
} catch {
|
||||
throw new DelegatedCredentialError(
|
||||
'delegated-authority-unavailable',
|
||||
|
||||
@@ -5,7 +5,7 @@ import { mkdtemp } from 'node:fs/promises';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { rm } from 'node:fs/promises';
|
||||
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;
|
||||
|
||||
@@ -56,6 +56,19 @@ describe('phase-1 governed file credential resolver', (): void => {
|
||||
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> => {
|
||||
const root = await fixtureRoot();
|
||||
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> => {
|
||||
const root = await fixtureRoot();
|
||||
await writeFile(join(root, 'gitea-example-shared.token'), 'shared-canary', { mode: 0o600 });
|
||||
|
||||
@@ -1,11 +1,31 @@
|
||||
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 { 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 { ParsedCredentialEstateRegistry } from './estate-registry.js';
|
||||
|
||||
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
||||
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 {
|
||||
constructor(
|
||||
@@ -61,6 +81,23 @@ export class FileCredentialResolver implements CredentialResolver {
|
||||
const hostConfig = this.estateRegistry.resolve(estate, host);
|
||||
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`);
|
||||
let snapshot: SecureFileSnapshot;
|
||||
try {
|
||||
@@ -74,6 +111,12 @@ export class FileCredentialResolver implements CredentialResolver {
|
||||
}
|
||||
|
||||
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) {
|
||||
throw new CredentialStoreError(
|
||||
'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' });
|
||||
}
|
||||
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);
|
||||
},
|
||||
@@ -213,6 +217,34 @@ describe('Gitea credential provider transport', (): void => {
|
||||
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> => {
|
||||
const adapter = new GiteaCredentialProviderAdapter(
|
||||
'https://git.example.invalid',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
import type { GiteaCredentialProvider, ResolvedCredential } from './credential-provider.dto.js';
|
||||
import type { GiteaGrantProvider } from './grant.js';
|
||||
import type { GiteaLifecycleProvider, MintedToken } from './lifecycle.js';
|
||||
import type { TokenObjectEvidenceDto } from './lifecycle.dto.js';
|
||||
import type {
|
||||
GiteaTeamGrantProvider,
|
||||
PresenceEvidence,
|
||||
@@ -43,6 +45,14 @@ const collaboratorPermissionSchema = z
|
||||
.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
|
||||
.object({
|
||||
id: z.number().int().positive(),
|
||||
@@ -202,7 +212,8 @@ function effectivePermission(permissions: {
|
||||
}): RepositoryPermission {
|
||||
if (permissions.admin) return 'admin';
|
||||
if (permissions.push) return 'write';
|
||||
return 'read';
|
||||
if (permissions.pull) return 'read';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
export class GiteaCredentialProviderAdapter implements GiteaCredentialProvider {
|
||||
@@ -211,14 +222,27 @@ export class GiteaCredentialProviderAdapter implements GiteaCredentialProvider {
|
||||
constructor(
|
||||
apiBaseUrl: string,
|
||||
private readonly fetchImpl: FetchLike = fetch,
|
||||
private readonly requestTimeoutMs = 10_000,
|
||||
) {
|
||||
const parsed = new URL(apiBaseUrl);
|
||||
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> {
|
||||
const deadline = AbortSignal.timeout(this.requestTimeoutMs);
|
||||
const signal = init.signal == null ? deadline : AbortSignal.any([init.signal, deadline]);
|
||||
try {
|
||||
return await this.fetchImpl(url, init);
|
||||
return await this.fetchImpl(url, { ...init, signal });
|
||||
} catch {
|
||||
throw new CredentialProviderEvidenceError(
|
||||
'provider-unavailable',
|
||||
@@ -610,6 +634,10 @@ export class GiteaTeamGrantProviderAdapter
|
||||
return this.readPresence(
|
||||
authority,
|
||||
`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(
|
||||
authority,
|
||||
`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(
|
||||
authority: ResolvedCredential,
|
||||
endpoint: string,
|
||||
matchesExpectedObject: (value: unknown) => boolean,
|
||||
): Promise<PresenceEvidence> {
|
||||
const response = await this.request(`${this.origin}${endpoint.slice(4)}`, {
|
||||
method: 'GET',
|
||||
@@ -645,7 +678,155 @@ export class GiteaTeamGrantProviderAdapter
|
||||
await boundedBody(response);
|
||||
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) };
|
||||
}
|
||||
}
|
||||
|
||||
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 { join } from 'node:path';
|
||||
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.organizationMembership?.state).toBe('absent');
|
||||
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> => {
|
||||
|
||||
@@ -106,6 +106,11 @@ export async function grantDirectRepositoryPermission(
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
};
|
||||
}
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: authorityIdentity.endpoint,
|
||||
contentType: authorityIdentity.contentType,
|
||||
decision: 'identity-verified',
|
||||
});
|
||||
mutation = 'unknown';
|
||||
await grantProvider.grantCollaborator(
|
||||
authority,
|
||||
@@ -114,6 +119,7 @@ export async function grantDirectRepositoryPermission(
|
||||
request.permission,
|
||||
);
|
||||
mutation = 'applied';
|
||||
await journal.recordMutation('collaborator-grant-applied');
|
||||
|
||||
const collaborator = await grantProvider.readCollaboratorPermission(
|
||||
authority,
|
||||
@@ -135,6 +141,34 @@ export async function grantDirectRepositoryPermission(
|
||||
? await evaluateGiteaReadValidation(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 =
|
||||
collaborator.identity === request.identity &&
|
||||
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,
|
||||
);
|
||||
}
|
||||
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);
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: teamRepositorySet.endpoint,
|
||||
contentType: teamRepositorySet.contentType,
|
||||
decision: 'team-repository-set-verified',
|
||||
});
|
||||
if (teamRepositorySet.repositories.some((repo): boolean => repo !== request.repo)) {
|
||||
await journal.seal('refused', 'team-scope-exceeds-request');
|
||||
return result(
|
||||
@@ -132,7 +147,9 @@ export async function grantTeamRepositoryPermission(
|
||||
mutation = 'unknown';
|
||||
await provider.addTeamMember(authority, team.id, request.identity);
|
||||
mutation = 'applied';
|
||||
await journal.recordMutation('team-member-applied');
|
||||
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 teamRepository = await provider.readTeamRepository(authority, team.id, request.repo);
|
||||
const subject = await dependencies.resolver.resolve(
|
||||
@@ -148,6 +165,33 @@ export async function grantTeamRepositoryPermission(
|
||||
request.permission === 'read'
|
||||
? await evaluateGiteaReadValidation(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 =
|
||||
teamMembership.state === 'present' &&
|
||||
teamRepository.state === 'present' &&
|
||||
|
||||
@@ -14,12 +14,12 @@ import {
|
||||
|
||||
interface FixtureOptions {
|
||||
readonly subjectProviderIdentity?: string;
|
||||
readonly subjectPermission?: 'read' | 'write' | 'admin';
|
||||
readonly subjectPermission?: 'none' | 'read' | 'write' | 'admin';
|
||||
readonly subjectTransportState?: 'advertised' | 'refused';
|
||||
readonly subjectTransportPrincipal?: string;
|
||||
readonly subjectTransportResolutionId?: string;
|
||||
readonly controlProviderIdentity?: string;
|
||||
readonly controlPermission?: 'read' | 'write' | 'admin';
|
||||
readonly controlPermission?: 'none' | 'read' | 'write' | 'admin';
|
||||
readonly controlTransportState?: 'advertised' | 'refused';
|
||||
readonly controlTransportPrincipal?: string;
|
||||
readonly unauthenticatedTransportState?: 'advertised' | 'refused';
|
||||
@@ -173,6 +173,17 @@ describe('Gitea read validation', (): void => {
|
||||
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> => {
|
||||
const observed = fixture({ subjectPermission: 'read' });
|
||||
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 => {
|
||||
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> => {
|
||||
const { result, observed } = await validate();
|
||||
|
||||
|
||||
@@ -239,6 +239,16 @@ async function evaluateGiteaReadValidationUnsafe(
|
||||
evidence,
|
||||
);
|
||||
}
|
||||
if (repositoryPermission.effective === 'none') {
|
||||
return refused(
|
||||
request,
|
||||
{
|
||||
code: 'permission-denied',
|
||||
message: 'The in-scope provider object denies repository access.',
|
||||
},
|
||||
evidence,
|
||||
);
|
||||
}
|
||||
return indeterminate(
|
||||
request,
|
||||
{
|
||||
@@ -273,6 +283,16 @@ async function evaluateGiteaReadValidationUnsafe(
|
||||
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) {
|
||||
return refused(
|
||||
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 = {
|
||||
providerIdentity: subjectEvidence.identity,
|
||||
repositoryPermission: subjectEvidence.permission,
|
||||
@@ -371,7 +444,10 @@ async function evaluateGiteaWriteValidationUnsafe(
|
||||
baseEvidence,
|
||||
);
|
||||
}
|
||||
if (subjectEvidence.permission.effective === 'read') {
|
||||
if (
|
||||
subjectEvidence.permission.effective === 'read' ||
|
||||
subjectEvidence.permission.effective === 'none'
|
||||
) {
|
||||
return refused(
|
||||
request,
|
||||
{
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface SecureFileSnapshot {
|
||||
mode: number;
|
||||
dev: number | bigint;
|
||||
ino: number | bigint;
|
||||
uid: number;
|
||||
gid: number;
|
||||
}
|
||||
|
||||
function sameIdentity(
|
||||
@@ -235,6 +237,8 @@ export function readRegularFileSecure(
|
||||
mode: Number(opened.mode),
|
||||
dev: opened.dev,
|
||||
ino: opened.ino,
|
||||
uid: opened.uid,
|
||||
gid: opened.gid,
|
||||
};
|
||||
} finally {
|
||||
closeDescriptors(openedFile.descriptors);
|
||||
|
||||
Reference in New Issue
Block a user