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