fix(mosaic): harden credential lifecycle boundaries
This commit is contained in:
@@ -539,7 +539,7 @@ get_gitea_token() {
|
|||||||
if [[ -n "$_idpfx" ]]; then
|
if [[ -n "$_idpfx" ]]; then
|
||||||
local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
|
local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
|
||||||
local _idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.credential.json"
|
local _idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.credential.json"
|
||||||
if [[ -r "$_idcred" ]]; then
|
if [[ -e "$_idcred" || -L "$_idcred" ]]; then
|
||||||
local _resolved_token
|
local _resolved_token
|
||||||
_resolved_token=$(python3 "$script_dir/resolve-credential-envelope.py" \
|
_resolved_token=$(python3 "$script_dir/resolve-credential-envelope.py" \
|
||||||
"$_idcred" "$_ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || return 1
|
"$_idcred" "$_ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || return 1
|
||||||
|
|||||||
@@ -38,25 +38,25 @@ trace_resolution() {
|
|||||||
# survives across non-persistent shells) > git-supplied username (credential.username
|
# survives across non-persistent shells) > git-supplied username (credential.username
|
||||||
# / URL). When the resolved identity has a matching per-agent token, use it instead of
|
# / URL). When the resolved identity has a matching per-agent token, use it instead of
|
||||||
# the shared account. Backward-compatible: nothing resolvable → shared token.
|
# the shared account. Backward-compatible: nothing resolvable → shared token.
|
||||||
|
case "$host" in
|
||||||
|
git.uscllc.com) idpfx=gitea-usc;;
|
||||||
|
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
|
||||||
|
*) idpfx="";;
|
||||||
|
esac
|
||||||
ident="$MOSAIC_GIT_IDENTITY"
|
ident="$MOSAIC_GIT_IDENTITY"
|
||||||
[ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
|
[ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
|
||||||
[ -z "$ident" ] && ident="$username_in"
|
[ -z "$ident" ] && ident="$username_in"
|
||||||
if [ -n "${MOSAIC_AGENT_NAME:-}" ] && [ -n "$ident" ] && [ "$ident" != "$MOSAIC_AGENT_NAME" ]; then
|
if [ -n "$idpfx" ] && [ -n "${MOSAIC_AGENT_NAME:-}" ] && [ -n "$ident" ] && [ "$ident" != "$MOSAIC_AGENT_NAME" ]; then
|
||||||
echo "quit=true"
|
echo "quit=true"
|
||||||
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=provider-identity-mismatch identity=%s fleet_identity=%s host=%s shared_path_entered=false source=git-credential-mosaic\n' \
|
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=provider-identity-mismatch identity=%s fleet_identity=%s host=%s shared_path_entered=false source=git-credential-mosaic\n' \
|
||||||
"$ident" "$MOSAIC_AGENT_NAME" "$host" >&2
|
"$ident" "$MOSAIC_AGENT_NAME" "$host" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [ -n "$ident" ]; then
|
if [ -n "$ident" ]; then
|
||||||
case "$host" in
|
|
||||||
git.uscllc.com) idpfx=gitea-usc;;
|
|
||||||
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
|
|
||||||
*) idpfx="";;
|
|
||||||
esac
|
|
||||||
if [ -n "$idpfx" ]; then
|
if [ -n "$idpfx" ]; then
|
||||||
idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.token"
|
idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.token"
|
||||||
idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.credential.json"
|
idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.credential.json"
|
||||||
if [ -r "$idcred" ]; then
|
if [ -e "$idcred" ] || [ -L "$idcred" ]; then
|
||||||
token=$(python3 "$script_dir/resolve-credential-envelope.py" \
|
token=$(python3 "$script_dir/resolve-credential-envelope.py" \
|
||||||
"$idcred" "$ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || exit 1
|
"$idcred" "$ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || exit 1
|
||||||
trace_resolution identity credential-resolved "$ident" "$host" git-credential-mosaic
|
trace_resolution identity credential-resolved "$ident" "$host" git-credential-mosaic
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ PY
|
|||||||
chmod 600 "$envelope"
|
chmod 600 "$envelope"
|
||||||
out=$(run_helper "git.mosaicstack.dev" "agentE" MOSAIC_AGENT_NAME=agentE MOSAIC_CREDENTIAL_ESTATE=homelab)
|
out=$(run_helper "git.mosaicstack.dev" "agentE" MOSAIC_AGENT_NAME=agentE MOSAIC_CREDENTIAL_ESTATE=homelab)
|
||||||
assert_eq "governed envelope: password" "password=agentE-envelope-token" "$(echo "$out" | grep '^password=')"
|
assert_eq "governed envelope: password" "password=agentE-envelope-token" "$(echo "$out" | grep '^password=')"
|
||||||
|
echo -n "must-not-fallback-legacy" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentE.token"
|
||||||
chmod 640 "$envelope"
|
chmod 640 "$envelope"
|
||||||
set +e
|
set +e
|
||||||
out=$(run_helper "git.mosaicstack.dev" "agentE" MOSAIC_AGENT_NAME=agentE MOSAIC_CREDENTIAL_ESTATE=homelab 2>"$WORK_DIR/envelope-mode.stderr")
|
out=$(run_helper "git.mosaicstack.dev" "agentE" MOSAIC_AGENT_NAME=agentE MOSAIC_CREDENTIAL_ESTATE=homelab 2>"$WORK_DIR/envelope-mode.stderr")
|
||||||
@@ -210,6 +211,8 @@ fi
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
out=$(run_helper "github.com" "agentA")
|
out=$(run_helper "github.com" "agentA")
|
||||||
assert_eq "unknown host: no output" "" "$out"
|
assert_eq "unknown host: no output" "" "$out"
|
||||||
|
out=$(run_helper "github.com" "github-user" MOSAIC_AGENT_NAME=agentA)
|
||||||
|
assert_eq "unknown host in fleet context: no output" "" "$out"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 9. Non-"get" verb (store/erase) -> exit 0, no output (git-credential
|
# 9. Non-"get" verb (store/erase) -> exit 0, no output (git-credential
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ interface CredentialValidateCommandOptions {
|
|||||||
readonly mosaicHome?: string;
|
readonly mosaicHome?: string;
|
||||||
readonly actor?: string;
|
readonly actor?: string;
|
||||||
readonly json?: boolean;
|
readonly json?: boolean;
|
||||||
|
readonly operation?: 'validate' | 'whoami';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CredentialGrantCommandOptions {
|
interface CredentialGrantCommandOptions {
|
||||||
@@ -111,10 +112,11 @@ function readRegistrySource(path: string): string {
|
|||||||
function errorResult(
|
function errorResult(
|
||||||
request: GiteaWriteValidationRequestDto,
|
request: GiteaWriteValidationRequestDto,
|
||||||
code: string,
|
code: string,
|
||||||
|
operation: 'validate' | 'whoami' = 'validate',
|
||||||
): CredentialValidationResultDto {
|
): CredentialValidationResultDto {
|
||||||
return {
|
return {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
operation: 'validate',
|
operation,
|
||||||
outcome: 'error',
|
outcome: 'error',
|
||||||
exitCode: 20,
|
exitCode: 20,
|
||||||
retryable: false,
|
retryable: false,
|
||||||
@@ -166,13 +168,13 @@ export async function executeCredentialValidate(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (!['read', 'write', 'admin'].includes(options.require)) {
|
if (!['read', 'write', 'admin'].includes(options.require)) {
|
||||||
return errorResult(request, 'invalid-input');
|
return errorResult(request, 'invalid-input', options.operation);
|
||||||
}
|
}
|
||||||
const registry = parseCredentialEstateRegistry(readRegistrySource(registryPath));
|
const registry = parseCredentialEstateRegistry(readRegistrySource(registryPath));
|
||||||
const hostConfig = registry.resolve(options.estate, options.host);
|
const hostConfig = registry.resolve(options.estate, options.host);
|
||||||
if (hostConfig === undefined) {
|
if (hostConfig === undefined) {
|
||||||
return {
|
return {
|
||||||
...errorResult(request, 'estate-host-mismatch'),
|
...errorResult(request, 'estate-host-mismatch', options.operation),
|
||||||
outcome: 'refused',
|
outcome: 'refused',
|
||||||
exitCode: 10,
|
exitCode: 10,
|
||||||
reason: {
|
reason: {
|
||||||
@@ -188,19 +190,23 @@ export async function executeCredentialValidate(
|
|||||||
const resolver = new FileCredentialResolver(tokenDirectory, registry);
|
const resolver = new FileCredentialResolver(tokenDirectory, registry);
|
||||||
const provider = new GiteaCredentialProviderAdapter(hostConfig.apiBaseUrl, fetch);
|
const provider = new GiteaCredentialProviderAdapter(hostConfig.apiBaseUrl, fetch);
|
||||||
const dependencies = { resolver, provider, estateRegistry: registry };
|
const dependencies = { resolver, provider, estateRegistry: registry };
|
||||||
const serviceOptions = { stateRoot, actor: options.actor ?? identity };
|
const serviceOptions = {
|
||||||
|
stateRoot,
|
||||||
|
actor: options.actor ?? identity,
|
||||||
|
operation: options.operation ?? 'validate',
|
||||||
|
};
|
||||||
if (options.require === 'read') {
|
if (options.require === 'read') {
|
||||||
return await runCredentialReadValidation(request, dependencies, serviceOptions);
|
return await runCredentialReadValidation(request, dependencies, serviceOptions);
|
||||||
}
|
}
|
||||||
return await runCredentialValidation(request, dependencies, serviceOptions);
|
return await runCredentialValidation(request, dependencies, serviceOptions);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (error instanceof CredentialJournalError) {
|
if (error instanceof CredentialJournalError) {
|
||||||
return errorResult(request, error.code);
|
return errorResult(request, error.code, options.operation);
|
||||||
}
|
}
|
||||||
if (error instanceof CredentialEstateRegistryError || error instanceof CredentialStoreError) {
|
if (error instanceof CredentialEstateRegistryError || error instanceof CredentialStoreError) {
|
||||||
return errorResult(request, error.code);
|
return errorResult(request, error.code, options.operation);
|
||||||
}
|
}
|
||||||
return errorResult(request, 'internal-invariant');
|
return errorResult(request, 'internal-invariant', options.operation);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,6 +511,14 @@ export async function executeCredentialRotate(
|
|||||||
message: 'No existing binding can be rotated.',
|
message: 'No existing binding can be rotated.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (options.tokenName === old.binding.tokenName) {
|
||||||
|
old.secret.fill(0);
|
||||||
|
return localLifecycleResult('rotate', identity, options, {
|
||||||
|
outcome: 'refused',
|
||||||
|
code: 'replacement-token-name-conflict',
|
||||||
|
message: 'Replacement token name must differ from the active generation.',
|
||||||
|
});
|
||||||
|
}
|
||||||
const authority = await lifecycleAuthority(identity, options);
|
const authority = await lifecycleAuthority(identity, options);
|
||||||
const journal = await CredentialAuditJournal.open(context.stateRoot, {
|
const journal = await CredentialAuditJournal.open(context.stateRoot, {
|
||||||
operation: 'rotate',
|
operation: 'rotate',
|
||||||
@@ -646,7 +660,7 @@ export async function executeCredentialWire(
|
|||||||
lines.push(
|
lines.push(
|
||||||
`MOSAIC_GIT_IDENTITY=${identity}`,
|
`MOSAIC_GIT_IDENTITY=${identity}`,
|
||||||
`MOSAIC_CREDENTIAL_ESTATE=${options.estate}`,
|
`MOSAIC_CREDENTIAL_ESTATE=${options.estate}`,
|
||||||
`GITEA_LOGIN=${identity}`,
|
`GITEA_LOGIN=${identity}--${options.host}`,
|
||||||
);
|
);
|
||||||
const temp = `${options.seatEnv}.${process.pid.toString()}.tmp`;
|
const temp = `${options.seatEnv}.${process.pid.toString()}.tmp`;
|
||||||
const handle = await open(temp, 'wx', 0o600);
|
const handle = await open(temp, 'wx', 0o600);
|
||||||
@@ -1051,7 +1065,11 @@ export function registerCredentialCommand(parent: Command): void {
|
|||||||
.option('--actor <identity>', 'Explicit audit actor')
|
.option('--actor <identity>', 'Explicit audit actor')
|
||||||
.option('--json', 'Emit one machine result object')
|
.option('--json', 'Emit one machine result object')
|
||||||
.action(async (identity: string, options: CredentialValidateCommandOptions): Promise<void> => {
|
.action(async (identity: string, options: CredentialValidateCommandOptions): Promise<void> => {
|
||||||
const result = await executeCredentialValidate(identity, { ...options, require: 'read' });
|
const result = await executeCredentialValidate(identity, {
|
||||||
|
...options,
|
||||||
|
require: 'read',
|
||||||
|
operation: 'whoami',
|
||||||
|
});
|
||||||
printCredentialResult(result, options.json === true);
|
printCredentialResult(result, options.json === true);
|
||||||
process.exitCode = result.exitCode;
|
process.exitCode = result.exitCode;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } 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';
|
||||||
@@ -107,6 +107,22 @@ describe('credential durable audit journal', (): void => {
|
|||||||
await journal.closeIncomplete();
|
await journal.closeIncomplete();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('refuses a group-writable journal root before opening evidence', async (): Promise<void> => {
|
||||||
|
const root = await stateRoot();
|
||||||
|
await mkdir(root, { mode: 0o700 });
|
||||||
|
await chmod(root, 0o770);
|
||||||
|
await expect(
|
||||||
|
CredentialAuditJournal.open(root, {
|
||||||
|
operation: 'grant',
|
||||||
|
actor: 'provisioner',
|
||||||
|
identity: 'seat-name',
|
||||||
|
estate: 'homelab',
|
||||||
|
host: 'git.example.invalid',
|
||||||
|
repo: 'owner/repo',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/journal-unavailable/);
|
||||||
|
});
|
||||||
|
|
||||||
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> => {
|
||||||
const root = await stateRoot();
|
const root = await stateRoot();
|
||||||
const journal = await CredentialAuditJournal.open(
|
const journal = await CredentialAuditJournal.open(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { lstatSync } from 'node:fs';
|
||||||
import { open, readdir, rename } from 'node:fs/promises';
|
import { open, readdir, rename } from 'node:fs/promises';
|
||||||
import type { FileHandle } from 'node:fs/promises';
|
import type { FileHandle } from 'node:fs/promises';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
@@ -31,6 +32,7 @@ const SAFE_DECISIONS = new Set<string>([
|
|||||||
'revoke-verified',
|
'revoke-verified',
|
||||||
'rotate-verified',
|
'rotate-verified',
|
||||||
'validation-requested',
|
'validation-requested',
|
||||||
|
'whoami-requested',
|
||||||
'provision-requested',
|
'provision-requested',
|
||||||
'rotate-requested',
|
'rotate-requested',
|
||||||
'revoke-requested',
|
'revoke-requested',
|
||||||
@@ -69,6 +71,21 @@ export class CredentialJournalError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertPrivateDirectory(path: string): void {
|
||||||
|
const stat = lstatSync(path);
|
||||||
|
if (
|
||||||
|
!stat.isDirectory() ||
|
||||||
|
stat.isSymbolicLink() ||
|
||||||
|
stat.uid !== process.getuid?.() ||
|
||||||
|
(stat.mode & 0o022) !== 0
|
||||||
|
) {
|
||||||
|
throw new CredentialJournalError(
|
||||||
|
'journal-unavailable',
|
||||||
|
'journal directory owner or write permissions are unsafe',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function assertContext(context: CredentialJournalContextDto): void {
|
function assertContext(context: CredentialJournalContextDto): void {
|
||||||
if (
|
if (
|
||||||
!SAFE_NAME.test(context.actor) ||
|
!SAFE_NAME.test(context.actor) ||
|
||||||
@@ -132,6 +149,8 @@ export class CredentialAuditJournal {
|
|||||||
let handle: FileHandle | undefined;
|
let handle: FileHandle | undefined;
|
||||||
try {
|
try {
|
||||||
ensureManagedDirectory(stateRoot, journalsDirectory);
|
ensureManagedDirectory(stateRoot, journalsDirectory);
|
||||||
|
assertPrivateDirectory(stateRoot);
|
||||||
|
assertPrivateDirectory(journalsDirectory);
|
||||||
const openPath = join(journalsDirectory, `${id}.open.jsonl`);
|
const openPath = join(journalsDirectory, `${id}.open.jsonl`);
|
||||||
handle = await open(openPath, 'wx', 0o600);
|
handle = await open(openPath, 'wx', 0o600);
|
||||||
const journal = new CredentialAuditJournal(handle, openPath, journalsDirectory, id, now);
|
const journal = new CredentialAuditJournal(handle, openPath, journalsDirectory, id, now);
|
||||||
@@ -270,6 +289,8 @@ export async function listCredentialJournals(
|
|||||||
const journalsDirectory = join(stateRoot, 'journals');
|
const journalsDirectory = join(stateRoot, 'journals');
|
||||||
let names: string[];
|
let names: string[];
|
||||||
try {
|
try {
|
||||||
|
assertPrivateDirectory(stateRoot);
|
||||||
|
assertPrivateDirectory(journalsDirectory);
|
||||||
names = await readdir(journalsDirectory);
|
names = await readdir(journalsDirectory);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return [];
|
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return [];
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export interface CredentialAuditResultDto {
|
|||||||
|
|
||||||
export interface CredentialValidationResultDto {
|
export interface CredentialValidationResultDto {
|
||||||
readonly schemaVersion: 1;
|
readonly schemaVersion: 1;
|
||||||
readonly operation: 'validate';
|
readonly operation: 'validate' | 'whoami';
|
||||||
readonly outcome: CredentialOutcome;
|
readonly outcome: CredentialOutcome;
|
||||||
readonly exitCode: 0 | 10 | 20 | 30;
|
readonly exitCode: 0 | 10 | 20 | 30;
|
||||||
readonly retryable: boolean;
|
readonly retryable: boolean;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { evaluateGiteaReadValidation, evaluateGiteaWriteValidation } from './val
|
|||||||
export interface CredentialValidationServiceOptions {
|
export interface CredentialValidationServiceOptions {
|
||||||
readonly stateRoot: string;
|
readonly stateRoot: string;
|
||||||
readonly actor: string;
|
readonly actor: string;
|
||||||
|
readonly operation?: 'validate' | 'whoami';
|
||||||
}
|
}
|
||||||
|
|
||||||
function permissionDecision(permission: RepositoryPermission): string {
|
function permissionDecision(permission: RepositoryPermission): string {
|
||||||
@@ -27,14 +28,16 @@ async function openValidationJournal(
|
|||||||
options: CredentialValidationServiceOptions,
|
options: CredentialValidationServiceOptions,
|
||||||
): Promise<CredentialAuditJournal> {
|
): Promise<CredentialAuditJournal> {
|
||||||
const journal = await CredentialAuditJournal.open(options.stateRoot, {
|
const journal = await CredentialAuditJournal.open(options.stateRoot, {
|
||||||
operation: 'validate',
|
operation: options.operation ?? 'validate',
|
||||||
actor: options.actor,
|
actor: options.actor,
|
||||||
identity: request.identity,
|
identity: request.identity,
|
||||||
estate: request.estate,
|
estate: request.estate,
|
||||||
host: request.host,
|
host: request.host,
|
||||||
repo: request.repo,
|
repo: request.repo,
|
||||||
});
|
});
|
||||||
await journal.recordIntent('validation-requested');
|
await journal.recordIntent(
|
||||||
|
options.operation === 'whoami' ? 'whoami-requested' : 'validation-requested',
|
||||||
|
);
|
||||||
return journal;
|
return journal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +73,10 @@ export async function runCredentialReadValidation(
|
|||||||
): Promise<CredentialValidationResultDto> {
|
): Promise<CredentialValidationResultDto> {
|
||||||
const journal = await openValidationJournal(request, options);
|
const journal = await openValidationJournal(request, options);
|
||||||
const validation = await evaluateGiteaReadValidation(request, dependencies);
|
const validation = await evaluateGiteaReadValidation(request, dependencies);
|
||||||
return recordAndSealValidation(journal, validation);
|
return recordAndSealValidation(journal, {
|
||||||
|
...validation,
|
||||||
|
operation: options.operation ?? 'validate',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runCredentialValidation(
|
export async function runCredentialValidation(
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ describe('credential estate registry', (): void => {
|
|||||||
expect(registry.matches('homelab', 'git.example.invalid')).toBe(true);
|
expect(registry.matches('homelab', 'git.example.invalid')).toBe(true);
|
||||||
expect(registry.matches('usc', 'git.example.invalid')).toBe(false);
|
expect(registry.matches('usc', 'git.example.invalid')).toBe(false);
|
||||||
expect(registry.matches('homelab', 'other.example.invalid')).toBe(false);
|
expect(registry.matches('homelab', 'other.example.invalid')).toBe(false);
|
||||||
|
expect(registry.resolveByHost('git.example.invalid')).toMatchObject({
|
||||||
|
estate: 'homelab',
|
||||||
|
host: { host: 'git.example.invalid', provider: 'gitea' },
|
||||||
|
});
|
||||||
|
expect(registry.resolveByHost('other.example.invalid')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a provider URL whose host differs from the declared host', (): void => {
|
it('rejects a provider URL whose host differs from the declared host', (): void => {
|
||||||
|
|||||||
@@ -93,6 +93,18 @@ export class ParsedCredentialEstateRegistry implements CredentialEstateRegistry
|
|||||||
?.hosts.find((candidate: CredentialHostConfigDto): boolean => candidate.host === host);
|
?.hosts.find((candidate: CredentialHostConfigDto): boolean => candidate.host === host);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolveByHost(
|
||||||
|
host: string,
|
||||||
|
): { readonly estate: string; readonly host: CredentialHostConfigDto } | undefined {
|
||||||
|
for (const [estate, config] of this.estates) {
|
||||||
|
const match = config.hosts.find(
|
||||||
|
(candidate: CredentialHostConfigDto): boolean => candidate.host === host,
|
||||||
|
);
|
||||||
|
if (match !== undefined) return { estate, host: match };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
readOnlyControl(estate: string): string {
|
readOnlyControl(estate: string): string {
|
||||||
const identity = this.estates.get(estate)?.readOnlyControlIdentity;
|
const identity = this.estates.get(estate)?.readOnlyControlIdentity;
|
||||||
if (identity === undefined) {
|
if (identity === undefined) {
|
||||||
|
|||||||
@@ -205,8 +205,9 @@ export async function provisionCredential(
|
|||||||
teaLogin: { ...teaLogin, state: 'registered' },
|
teaLogin: { ...teaLogin, state: 'registered' },
|
||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (error instanceof CredentialJournalError) throw error;
|
const journalFailure = error instanceof CredentialJournalError;
|
||||||
if (minted === undefined) {
|
if (minted === undefined) {
|
||||||
|
if (journalFailure) throw error;
|
||||||
await journal.seal('indeterminate', 'provider-unavailable');
|
await journal.seal('indeterminate', 'provider-unavailable');
|
||||||
return lifecycleResult('provision', request, {
|
return lifecycleResult('provision', request, {
|
||||||
outcome: 'indeterminate',
|
outcome: 'indeterminate',
|
||||||
@@ -231,13 +232,19 @@ export async function provisionCredential(
|
|||||||
await teaStore.put(request.identity, request.host, prior.secret);
|
await teaStore.put(request.identity, request.host, prior.secret);
|
||||||
}
|
}
|
||||||
rollbackComplete = true;
|
rollbackComplete = true;
|
||||||
await journal.recordMutation('provision-rollback-verified');
|
|
||||||
} catch {
|
} catch {
|
||||||
rollbackComplete = false;
|
rollbackComplete = false;
|
||||||
} finally {
|
} finally {
|
||||||
prior?.secret.fill(0);
|
prior?.secret.fill(0);
|
||||||
}
|
}
|
||||||
|
if (journalFailure) {
|
||||||
|
if (rollbackComplete) {
|
||||||
|
await journal.recordMutation('provision-rollback-verified').catch((): void => undefined);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
const code = rollbackComplete ? failureCode : 'rollback-incomplete';
|
const code = rollbackComplete ? failureCode : 'rollback-incomplete';
|
||||||
|
if (rollbackComplete) await journal.recordMutation('provision-rollback-verified');
|
||||||
await journal.seal(rollbackComplete ? 'error' : 'indeterminate', code);
|
await journal.seal(rollbackComplete ? 'error' : 'indeterminate', code);
|
||||||
return lifecycleResult('provision', request, {
|
return lifecycleResult('provision', request, {
|
||||||
outcome: rollbackComplete ? 'error' : 'indeterminate',
|
outcome: rollbackComplete ? 'error' : 'indeterminate',
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ describe('host-bound Tea login store', (): void => {
|
|||||||
await store.remove('seat', 'git.one.invalid');
|
await store.remove('seat', 'git.one.invalid');
|
||||||
expect(store.readBack('seat', 'git.one.invalid')).toBeUndefined();
|
expect(store.readBack('seat', 'git.one.invalid')).toBeUndefined();
|
||||||
expect(store.readBack('seat', 'git.two.invalid')).toEqual({
|
expect(store.readBack('seat', 'git.two.invalid')).toEqual({
|
||||||
name: 'seat',
|
name: 'seat--git.two.invalid',
|
||||||
host: 'git.two.invalid',
|
host: 'git.two.invalid',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ async function acquireLock(path: string): Promise<Awaited<ReturnType<typeof open
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loginName(identity: string, host: string): string {
|
||||||
|
return `${identity}--${host}`;
|
||||||
|
}
|
||||||
|
|
||||||
function assertPrivate(snapshot: { readonly mode: number; readonly uid: number }): void {
|
function assertPrivate(snapshot: { readonly mode: number; readonly uid: number }): void {
|
||||||
if ((snapshot.mode & 0o077) !== 0 || snapshot.uid !== process.getuid?.()) {
|
if ((snapshot.mode & 0o077) !== 0 || snapshot.uid !== process.getuid?.()) {
|
||||||
throw new TeaLoginStoreError('tea-config-insecure', 'Tea config is not private');
|
throw new TeaLoginStoreError('tea-config-insecure', 'Tea config is not private');
|
||||||
@@ -98,14 +102,15 @@ export class TeaLoginStore {
|
|||||||
}
|
}
|
||||||
const token = Buffer.from(secret).toString('utf8');
|
const token = Buffer.from(secret).toString('utf8');
|
||||||
const record: TeaLoginRecord = {
|
const record: TeaLoginRecord = {
|
||||||
name: identity,
|
name: loginName(identity, host),
|
||||||
url: `https://${host}`,
|
url: `https://${host}`,
|
||||||
token,
|
token,
|
||||||
user: identity,
|
user: identity,
|
||||||
default: false,
|
default: false,
|
||||||
};
|
};
|
||||||
const logins = current.logins.filter(
|
const logins = current.logins.filter(
|
||||||
(login): boolean => !(login.name === identity && login.url === `https://${host}`),
|
(login): boolean =>
|
||||||
|
!(login.name === loginName(identity, host) && login.url === `https://${host}`),
|
||||||
);
|
);
|
||||||
logins.push(record);
|
logins.push(record);
|
||||||
const temp = `${this.configPath}.${randomUUID()}.tmp`;
|
const temp = `${this.configPath}.${randomUUID()}.tmp`;
|
||||||
@@ -140,7 +145,9 @@ export class TeaLoginStore {
|
|||||||
if (!decoded.success) return undefined;
|
if (!decoded.success) return undefined;
|
||||||
const matches = decoded.data.logins.filter(
|
const matches = decoded.data.logins.filter(
|
||||||
(login): boolean =>
|
(login): boolean =>
|
||||||
login.name === identity && login.url === `https://${host}` && login.user === identity,
|
login.name === loginName(identity, host) &&
|
||||||
|
login.url === `https://${host}` &&
|
||||||
|
login.user === identity,
|
||||||
);
|
);
|
||||||
if (matches.length !== 1 || matches[0] === undefined) return undefined;
|
if (matches.length !== 1 || matches[0] === undefined) return undefined;
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
@@ -173,7 +180,8 @@ export class TeaLoginStore {
|
|||||||
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
|
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
|
||||||
}
|
}
|
||||||
const logins = decoded.data.logins.filter(
|
const logins = decoded.data.logins.filter(
|
||||||
(login): boolean => !(login.name === identity && login.url === `https://${host}`),
|
(login): boolean =>
|
||||||
|
!(login.name === loginName(identity, host) && login.url === `https://${host}`),
|
||||||
);
|
);
|
||||||
const temp = `${this.configPath}.${randomUUID()}.tmp`;
|
const temp = `${this.configPath}.${randomUUID()}.tmp`;
|
||||||
const handle = await open(temp, 'wx', 0o600);
|
const handle = await open(temp, 'wx', 0o600);
|
||||||
@@ -215,13 +223,13 @@ export class TeaLoginStore {
|
|||||||
if (typeof value !== 'object' || value === null) return false;
|
if (typeof value !== 'object' || value === null) return false;
|
||||||
return (
|
return (
|
||||||
'name' in value &&
|
'name' in value &&
|
||||||
value.name === identity &&
|
value.name === loginName(identity, host) &&
|
||||||
'url' in value &&
|
'url' in value &&
|
||||||
value.url === `https://${host}` &&
|
value.url === `https://${host}` &&
|
||||||
'user' in value &&
|
'user' in value &&
|
||||||
value.user === identity
|
value.user === identity
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return matches.length === 1 ? { name: identity, host } : undefined;
|
return matches.length === 1 ? { name: loginName(identity, host), host } : undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user