From 00d3f9a3fa3bfb658439c7e9e0704a9e1f9fad71 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 9 Aug 2026 08:00:48 +0000 Subject: [PATCH] feat(accounts): serve harness credentials from the vault, and mint keys as a principal The harness asks a host for an account bearer and a per-database data-plane token; VaultCredentials answers both out of the unlocked vault, so consumers stop keeping their own plaintext account.json. It refuses rather than guesses when the answer is ambiguous. Keys can also be minted as a Constructive principal - a scoped sub-identity that can only narrow its owner - so a CI key carries its own permission mask instead of a copy of a human's access. --- packages/accounts/README.md | 48 +++- packages/accounts/__tests__/accounts.test.ts | 103 +++++++++ .../accounts/__tests__/credentials.test.ts | 160 ++++++++++++++ packages/accounts/src/client.ts | 167 ++++++++++++-- packages/accounts/src/credentials.ts | 81 +++++++ packages/accounts/src/index.ts | 1 + packages/accounts/src/manager.ts | 100 +++++++++ packages/accounts/src/types.ts | 53 +++++ packages/cli/__tests__/account.test.ts | 31 +++ packages/cli/src/commands/account.ts | 209 ++++++++++++++++++ 10 files changed, 932 insertions(+), 21 deletions(-) create mode 100644 packages/accounts/__tests__/credentials.test.ts create mode 100644 packages/accounts/src/credentials.ts diff --git a/packages/accounts/README.md b/packages/accounts/README.md index 24000f3..e73fced 100644 --- a/packages/accounts/README.md +++ b/packages/accounts/README.md @@ -61,11 +61,57 @@ and audited like every other vault entry. | Item | Fields | | --------- | ------------------------------------------------------------------ | | `account` | `endpoint`, `email`, `user_id`, `access_token`\*, `access_token_expires_at` | -| `api_key` | `endpoint`, `account_id`, `key_id`, `api_key`\*, `expires_at` | +| `api_key` | `endpoint`, `account_id`, `key_id`, `api_key`\*, `expires_at`, `database_id`, `principal_id`, `org_id` | \* concealed. `accessToken()` returns `null` rather than an expired token, so a caller cannot present a dead credential by accident. +## Principals + +A principal is a scoped sub-identity — what an API key or an agent actually acts as. It is owned by +a human account and can only ever *narrow* that human: read-only, restricted per scope by a +permission mask, and optionally allowed to skip MFA step-up so CI is not blocked on a phone. + +```typescript +const principalId = await accounts.createPrincipal(account.itemId, { + name: 'ci-deploy', + orgId, + isReadOnly: true, + bypassStepUp: true, +}); + +// mint the key *as* the principal, so it carries the principal's scope +await accounts.createApiKey(account.itemId, { name: 'ci', principalId, orgId }); + +for (const principal of await accounts.listPrincipals(account.itemId)) { + principal.entityIds; // what it reaches + principal.scopes; // per-scope overrides: allowedMask, isActive, isReadOnly +} +``` + +A scope with no override row means the principal inherits its owner there; `allowedMask` is +AND-ed with the owner's permissions during the SPRT cascade, so an override can only take access +away. Principals are read from the server on demand rather than cached, because a stale local copy +of someone's permissions is worse than none. + +## Serving a harness + +`VaultCredentials` is a credential provider shaped like the harness contract, reading from the +unlocked vault instead of a plaintext `account.json`: + +```typescript +const credentials = new VaultCredentials(accounts); + +await credentials.accountBearer(); // the signed-in account's token, or null +await credentials.dataToken(databaseId); // { token, origin: 'vault' } +``` + +It refuses rather than guesses: `null` when no account is signed in, when several are and none was +named, when the token has expired, and when no key — or more than one — is tagged for that +database. Tag one with `accounts.assignKeyToDatabase(keyItemId, databaseId)` or +`createApiKey({ databaseId })`. Nothing is cached; every call re-reads the vault, so locking the +vault cuts every consumer off at once. + ## Testing without a server `AccountManager` takes an `AuthClientFactory`, so the whole vault side runs against a fake: diff --git a/packages/accounts/__tests__/accounts.test.ts b/packages/accounts/__tests__/accounts.test.ts index 0675b05..32bfe75 100644 --- a/packages/accounts/__tests__/accounts.test.ts +++ b/packages/accounts/__tests__/accounts.test.ts @@ -9,7 +9,9 @@ import { AuthClientFactory, AuthError, CreateApiKeyOptions, + CreatePrincipalOptions, hasExpired, + PrincipalRecord, StepUpKind, stepUpKind, StepUpRequiredError, @@ -26,6 +28,7 @@ const ENDPOINT = 'http://auth.localhost:3000/graphql'; class FakeServer { readonly calls: Array<{ operation: string; token?: string }> = []; private nextKey = 0; + principals: PrincipalRecord[] = []; expiresAt: string | null = null; /** Refuse sensitive calls with this factor until it has been re-proved. */ demandStepUp: StepUpKind | null = null; @@ -81,6 +84,32 @@ class FakeServer { this.calls.push({ operation: `revokeApiKey:${keyId}`, token }); this.refuseWithoutStepUp('revokeApiKey'); }, + listPrincipals: async () => { + this.calls.push({ operation: 'listPrincipals', token }); + return this.principals; + }, + createPrincipal: async (options: CreatePrincipalOptions) => { + this.calls.push({ operation: 'createPrincipal', token }); + this.refuseWithoutStepUp('createPrincipal'); + const principal: PrincipalRecord = { + principalId: `principal-${this.principals.length + 1}`, + name: options.name, + ownerId: 'user-dev@example.com', + isReadOnly: options.isReadOnly ?? false, + bypassStepUp: options.bypassStepUp ?? false, + useAdminOwner: options.useAdminOwner ?? true, + entityIds: [options.orgId], + scopes: [], + }; + this.principals.push(principal); + return principal.principalId; + }, + deletePrincipal: async (principalId: string) => { + this.calls.push({ operation: `deletePrincipal:${principalId}`, token }); + this.principals = this.principals.filter( + (principal) => principal.principalId !== principalId + ); + }, }); /** Mimics the server's `STEP_UP_REQUIRED_*` exceptions. */ @@ -469,6 +498,80 @@ describe('linked one-time codes', () => { }); }); +describe('principals', () => { + const signIn = () => + accounts.signIn({ + endpoint: ENDPOINT, + email: 'dev@example.com', + password: 'hunter22', + }); + + it('creates a scoped sub-identity and mints a key as it', async () => { + const account = await signIn(); + const principalId = await accounts.createPrincipal(account.itemId, { + name: 'ci-deploy', + orgId: 'org-1', + isReadOnly: true, + bypassStepUp: true, + }); + + const principals = await accounts.listPrincipals(account.itemId); + expect(principals).toHaveLength(1); + expect(principals[0]).toMatchObject({ + principalId, + name: 'ci-deploy', + isReadOnly: true, + bypassStepUp: true, + entityIds: ['org-1'], + }); + + const key = await accounts.createApiKey(account.itemId, { + name: 'ci', + principalId, + orgId: 'org-1', + }); + expect(key.principalId).toBe(principalId); + expect(key.orgId).toBe('org-1'); + expect((await accounts.listApiKeys())[0].principalId).toBe(principalId); + }); + + it('answers a step-up when creating one, like every other sensitive call', async () => { + const account = await signIn(); + server.demandStepUp = 'password'; + + await expect( + accounts.createPrincipal(account.itemId, { name: 'ci', orgId: 'org-1' }) + ).rejects.toBeInstanceOf(StepUpRequiredError); + + const principalId = await accounts.createPrincipal( + account.itemId, + { name: 'ci', orgId: 'org-1' }, + { password: 'hunter22' } + ); + expect(principalId).toBe('principal-1'); + }); + + it('deletes one server-side', async () => { + const account = await signIn(); + const principalId = await accounts.createPrincipal(account.itemId, { + name: 'ci', + orgId: 'org-1', + }); + + await accounts.deletePrincipal(account.itemId, principalId); + expect(await accounts.listPrincipals(account.itemId)).toHaveLength(0); + }); + + it('refuses to reach the server when the account is signed out', async () => { + const account = await signIn(); + await accounts.signOut(account.itemId); + + await expect(accounts.listPrincipals(account.itemId)).rejects.toThrow( + 'signed out' + ); + }); +}); + describe('hasExpired', () => { const now = new Date('2026-01-01T00:00:00.000Z'); diff --git a/packages/accounts/__tests__/credentials.test.ts b/packages/accounts/__tests__/credentials.test.ts new file mode 100644 index 0000000..0a65dba --- /dev/null +++ b/packages/accounts/__tests__/credentials.test.ts @@ -0,0 +1,160 @@ +import { Vault } from '@decryption/vault'; +import { promises as fs } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { + AccountManager, + AuthClient, + AuthClientFactory, + VaultCredentials, +} from '../src'; + +jest.setTimeout(120000); + +const MODULE_PATH = path.resolve(__dirname, '../../../pgpm-modules/dcrypt-vault'); +const FAST = { t: 1, m: 8192, p: 1 }; +const PASSPHRASE = 'a rather long master passphrase'; +const ENDPOINT = 'http://auth.localhost:3000/graphql'; + +let nextKey = 0; + +const factory: AuthClientFactory = (): AuthClient => ({ + signIn: async ({ email }) => ({ + userId: `user-${email}`, + accessToken: `token-${email}`, + accessTokenExpiresAt: null, + }), + signUp: async ({ email }) => ({ + userId: `user-${email}`, + accessToken: `token-${email}`, + accessTokenExpiresAt: null, + }), + signOut: async () => {}, + verifyPassword: async () => {}, + verifyTotp: async () => {}, + createApiKey: async (options) => { + nextKey += 1; + return { + apiKey: `cnc_live_sk_${options.name}`, + keyId: `key-${nextKey}`, + expiresAt: null, + }; + }, + revokeApiKey: async () => {}, + listPrincipals: async () => [], + createPrincipal: async () => 'principal-1', + deletePrincipal: async () => {}, +}); + +let dir: string; +let vault: Vault; +let accounts: AccountManager; + +const signIn = (email: string) => + accounts.signIn({ endpoint: ENDPOINT, email, password: 'hunter22' }); + +beforeAll(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dcrypt-credentials-')); + vault = await Vault.open({ + file: path.join(dir, 'vault.dcrypt'), + passphrase: PASSPHRASE, + modulePath: MODULE_PATH, + kdf: FAST, + }); +}); + +afterAll(async () => { + await vault.discard(); + await fs.rm(dir, { recursive: true, force: true }); +}); + +beforeEach(async () => { + for (const kind of ['account', 'api_key'] as const) { + for (const item of await vault.listItems({ kind })) { + await vault.deleteItemForever(item.id); + } + } + accounts = new AccountManager(vault, { createClient: factory }); +}); + +describe('VaultCredentials', () => { + it('serves the bearer of the one signed-in account', async () => { + await signIn('dev@example.com'); + const credentials = new VaultCredentials(accounts); + expect(await credentials.accountBearer()).toBe('token-dev@example.com'); + }); + + it('serves nothing rather than guess when several accounts are signed in', async () => { + await signIn('dev@example.com'); + await signIn('ops@example.com'); + + expect(await new VaultCredentials(accounts).accountBearer()).toBeNull(); + }); + + it('serves the named account even when several are signed in', async () => { + const dev = await signIn('dev@example.com'); + await signIn('ops@example.com'); + + const credentials = new VaultCredentials(accounts, { accountItemId: dev.itemId }); + expect(await credentials.accountBearer()).toBe('token-dev@example.com'); + }); + + it('serves nothing once the account is signed out', async () => { + const account = await signIn('dev@example.com'); + const credentials = new VaultCredentials(accounts, { + accountItemId: account.itemId, + }); + + await accounts.signOut(account.itemId); + expect(await credentials.accountBearer()).toBeNull(); + }); + + it('hands over the key tagged for a database, and nothing else', async () => { + const account = await signIn('dev@example.com'); + await accounts.createApiKey(account.itemId, { + name: 'app-data', + databaseId: 'db-1', + }); + await accounts.createApiKey(account.itemId, { name: 'unrelated' }); + + const credentials = new VaultCredentials(accounts); + expect(await credentials.dataToken('db-1')).toEqual({ + token: 'cnc_live_sk_app-data', + origin: 'vault', + }); + expect(await credentials.dataToken('db-2')).toEqual({ token: null }); + }); + + it('tags a key that already exists', async () => { + const account = await signIn('dev@example.com'); + const key = await accounts.createApiKey(account.itemId, { name: 'ci' }); + expect(key.databaseId).toBeNull(); + + await accounts.assignKeyToDatabase(key.itemId, 'db-9'); + + const credentials = new VaultCredentials(accounts); + expect((await credentials.dataToken('db-9')).token).toBe('cnc_live_sk_ci'); + expect((await accounts.listApiKeys())[0].databaseId).toBe('db-9'); + }); + + it('serves a data token from a signed-out account, because a key is its own credential', async () => { + const account = await signIn('dev@example.com'); + await accounts.createApiKey(account.itemId, { name: 'ci', databaseId: 'db-1' }); + await accounts.signOut(account.itemId); + + const credentials = new VaultCredentials(accounts); + expect((await credentials.dataToken('db-1')).token).toBe('cnc_live_sk_ci'); + expect(await credentials.accountBearer()).toBeNull(); + }); + + it('refuses when two keys claim the same database', async () => { + const account = await signIn('dev@example.com'); + await accounts.createApiKey(account.itemId, { name: 'one', databaseId: 'db-1' }); + await accounts.createApiKey(account.itemId, { name: 'two', databaseId: 'db-1' }); + + expect(await new VaultCredentials(accounts).dataToken('db-1')).toEqual({ + token: null, + }); + }); +}); diff --git a/packages/accounts/src/client.ts b/packages/accounts/src/client.ts index 94830d2..43e6862 100644 --- a/packages/accounts/src/client.ts +++ b/packages/accounts/src/client.ts @@ -1,7 +1,13 @@ import { auth } from '@constructive-io/sdk'; import { normalizeEndpoint } from './endpoint'; -import { AuthSession, CreateApiKeyOptions, CreatedApiKey } from './types'; +import { + AuthSession, + CreateApiKeyOptions, + CreatedApiKey, + CreatePrincipalOptions, + PrincipalRecord, +} from './types'; /** Raised when the auth server refuses a call, with the server's own wording. */ export class AuthError extends Error { @@ -62,6 +68,11 @@ export interface AuthClient { verifyPassword(password: string): Promise; /** Re-prove MFA with a one-time code. */ verifyTotp(code: string): Promise; + /** The caller's scoped sub-identities, with their scopes and masks. */ + listPrincipals(): Promise; + /** Create a principal scoped to an organization; returns its id. */ + createPrincipal(options: CreatePrincipalOptions): Promise; + deletePrincipal(principalId: string): Promise; } export interface AuthClientOptions { @@ -129,6 +140,48 @@ export const stepUpKind = (message: string): StepUpKind | null => { return (found[1]?.toLowerCase() as StepUpKind) ?? 'password'; }; +type PrincipalNode = { + id: string; + name?: string | null; + ownerId?: string | null; + isReadOnly?: boolean | null; + bypassStepUp?: boolean | null; + useAdminOwner?: boolean | null; + principalEntities?: { nodes: Array<{ entityId?: string | null }> }; + principalScopeOverrides?: { + nodes: Array<{ + membershipType?: number | null; + allowedMask?: string | null; + isActive?: boolean | null; + isReadOnly?: boolean | null; + useAdminOwner?: boolean | null; + }>; + }; +}; + +/** + * The server's defaults are the permissive ones: a principal inherits its + * owner unless a row says otherwise, so a missing flag reads as "inherits". + */ +const readPrincipal = (node: PrincipalNode): PrincipalRecord => ({ + principalId: node.id, + name: node.name ?? '', + ownerId: node.ownerId ?? null, + isReadOnly: node.isReadOnly ?? false, + bypassStepUp: node.bypassStepUp ?? false, + useAdminOwner: node.useAdminOwner ?? true, + entityIds: (node.principalEntities?.nodes ?? []) + .map((entity) => entity.entityId) + .filter((id): id is string => Boolean(id)), + scopes: (node.principalScopeOverrides?.nodes ?? []).map((scope) => ({ + membershipType: scope.membershipType ?? 0, + allowedMask: scope.allowedMask ?? null, + isActive: scope.isActive ?? true, + isReadOnly: scope.isReadOnly ?? false, + useAdminOwner: scope.useAdminOwner ?? true, + })), +}); + const rethrow = (operation: string, endpoint: string, error: unknown): never => { const message = error instanceof Error ? error.message : String(error); const kind = stepUpKind(message); @@ -193,26 +246,26 @@ export const sdkAuthClient: AuthClientFactory = (options) => { }, async createApiKey(options) { + const input = { + keyName: options.name, + expiresIn: options.expiresIn, + accessLevel: options.accessLevel, + principalId: options.principalId, + }; + const select = { + select: { + result: { select: { apiKey: true, keyId: true, expiresAt: true } }, + }, + } as const; try { - const data = await client.mutation - .createApiKey( - { - input: { - keyName: options.name, - expiresIn: options.expiresIn, - accessLevel: options.accessLevel, - }, - }, - { - select: { - result: { - select: { apiKey: true, keyId: true, expiresAt: true }, - }, - }, - } - ) - .unwrap(); - const result = data.createApiKey?.result; + const result = options.orgId + ? ( + await client.mutation + .createOrgApiKey({ input: { ...input, orgId: options.orgId } }, select) + .unwrap() + ).createOrgApiKey?.result + : (await client.mutation.createApiKey({ input }, select).unwrap()) + .createApiKey?.result; if (!result?.apiKey || !result.keyId) { throw new AuthError('createApiKey', 'the server returned no key'); } @@ -227,6 +280,80 @@ export const sdkAuthClient: AuthClientFactory = (options) => { } }, + async listPrincipals() { + try { + const data = await client.principal + .findMany({ + first: 200, + select: { + id: true, + name: true, + ownerId: true, + isReadOnly: true, + bypassStepUp: true, + useAdminOwner: true, + principalEntities: { select: { entityId: true }, first: 100 }, + principalScopeOverrides: { + select: { + membershipType: true, + allowedMask: true, + isActive: true, + isReadOnly: true, + useAdminOwner: true, + }, + first: 100, + }, + }, + }) + .unwrap(); + return data.principals.nodes.map(readPrincipal); + } catch (error) { + if (error instanceof AuthError) throw error; + return rethrow('listPrincipals', endpoint, error); + } + }, + + async createPrincipal(options) { + try { + const data = await client.mutation + .createOrgPrincipal( + { + input: { + name: options.name, + orgId: options.orgId, + isReadOnly: options.isReadOnly, + bypassStepUp: options.bypassStepUp, + useAdminOwner: options.useAdminOwner, + }, + }, + { select: { result: true } } + ) + .unwrap(); + const principalId = data.createOrgPrincipal?.result; + if (!principalId) { + throw new AuthError('createPrincipal', 'the server returned no principal'); + } + return principalId; + } catch (error) { + if (error instanceof AuthError) throw error; + return rethrow('createPrincipal', endpoint, error); + } + }, + + async deletePrincipal(principalId) { + try { + await client.mutation + .deletePrincipal( + { input: { principalId } }, + { select: { clientMutationId: true } } + ) + .unwrap(); + } catch (error) { + if (error instanceof AuthError) throw error; + rethrow('deletePrincipal', endpoint, error); + } + }, + async verifyPassword(password) { try { const data = await client.mutation diff --git a/packages/accounts/src/credentials.ts b/packages/accounts/src/credentials.ts new file mode 100644 index 0000000..733821d --- /dev/null +++ b/packages/accounts/src/credentials.ts @@ -0,0 +1,81 @@ +import { AccountManager } from './manager'; +import type { AccountRecord } from './types'; + +/** + * Result of a data-plane token request, structurally the harness's own + * `DataTokenResult`. The shape is duplicated rather than imported so this + * package does not depend on the harness to serve it — the harness is + * auth-agnostic by design and asks only for these two methods. + */ +export interface DataTokenResult { + token: string | null; + /** Where the token came from; always the vault when dcrypt answers. */ + origin?: string; +} + +/** The credential contract a harness host supplies (`HarnessCredentials`). */ +export interface CredentialProvider { + accountBearer(): Promise; + dataToken(databaseId: string): Promise; +} + +export interface VaultCredentialsOptions { + /** Serve this account. Otherwise the one signed-in account is used. */ + accountItemId?: string; +} + +/** + * Serves a harness its credentials out of the unlocked vault, so the harness, + * the CLI and any MCP host stop each keeping their own copy of a token on + * disk. Locking dcrypt cuts every one of them off at once, and there is a + * single place to revoke. + * + * Nothing is cached: every call re-reads the vault, because a token that was + * valid when the provider was constructed says nothing about now. + */ +export class VaultCredentials implements CredentialProvider { + constructor( + private readonly accounts: AccountManager, + private readonly options: VaultCredentialsOptions = {} + ) {} + + /** The control-plane bearer, or null when signed out, expired or ambiguous. */ + async accountBearer(): Promise { + const account = await this.account(); + return account ? this.accounts.accessToken(account.itemId) : null; + } + + /** + * The data-plane token for one provisioned database: the API key tagged with + * that database id. An untagged key is never handed over — a key minted for + * something else is not this database's token, and guessing would hand a + * caller more authority than it asked for. + * + * An API key is its own credential, so this does not require a live session; + * a signed-out account can still serve the key it minted. + */ + async dataToken(databaseId: string): Promise { + const keys = await this.accounts.listApiKeys(this.options.accountItemId); + const matches = keys.filter((key) => key.databaseId === databaseId); + if (matches.length !== 1) return { token: null }; + return { + token: await this.accounts.revealApiKey(matches[0].itemId), + origin: 'vault', + }; + } + + /** + * Which account to serve. With no explicit choice, exactly one account must + * be signed in: picking for the caller when several are would silently act + * as the wrong user, which is worse than refusing. + */ + private async account(): Promise { + if (this.options.accountItemId) { + return this.accounts.getAccount(this.options.accountItemId); + } + const signedIn = (await this.accounts.listAccounts()).filter( + (account) => account.signedIn + ); + return signedIn.length === 1 ? signedIn[0] : null; + } +} diff --git a/packages/accounts/src/index.ts b/packages/accounts/src/index.ts index 694c373..78f2ca0 100644 --- a/packages/accounts/src/index.ts +++ b/packages/accounts/src/index.ts @@ -1,4 +1,5 @@ export * from './client'; +export * from './credentials'; export * from './endpoint'; export * from './manager'; export * from './types'; diff --git a/packages/accounts/src/manager.ts b/packages/accounts/src/manager.ts index d981586..8be6421 100644 --- a/packages/accounts/src/manager.ts +++ b/packages/accounts/src/manager.ts @@ -15,6 +15,8 @@ import { ApiKeyRecord, AuthSession, CreateApiKeyOptions, + CreatePrincipalOptions, + PrincipalRecord, } from './types'; const ACCOUNT_FIELDS = { @@ -32,6 +34,9 @@ const KEY_FIELDS = { keyId: 'key_id', apiKey: 'api_key', expiresAt: 'expires_at', + databaseId: 'database_id', + principalId: 'principal_id', + orgId: 'org_id', } as const; export interface AccountManagerOptions { @@ -208,6 +213,27 @@ export class AccountManager { ); await this.vault.setField(item.id, KEY_FIELDS.keyId, 'text', created.keyId, false); await this.vault.setField(item.id, KEY_FIELDS.apiKey, 'token', created.apiKey); + if (options.principalId) { + await this.vault.setField( + item.id, + KEY_FIELDS.principalId, + 'text', + options.principalId, + false + ); + } + if (options.orgId) { + await this.vault.setField(item.id, KEY_FIELDS.orgId, 'text', options.orgId, false); + } + if (options.databaseId) { + await this.vault.setField( + item.id, + KEY_FIELDS.databaseId, + 'text', + options.databaseId, + false + ); + } if (created.expiresAt) { await this.vault.setField( item.id, @@ -225,9 +251,27 @@ export class AccountManager { keyId: created.keyId, name: options.name, expiresAt: created.expiresAt, + databaseId: options.databaseId ?? null, + principalId: options.principalId ?? null, + orgId: options.orgId ?? null, }; } + /** + * Say that a key already in the vault is a database's data-plane token, so a + * harness host asking for that database is handed this key and no other. + */ + async assignKeyToDatabase(keyItemId: string, databaseId: string): Promise { + await this.requireItem(keyItemId, 'api_key'); + await this.vault.setField( + keyItemId, + KEY_FIELDS.databaseId, + 'text', + databaseId, + false + ); + } + /** Every stored key, or only those minted by one account. */ async listApiKeys(accountItemId?: string): Promise { const items = await this.vault.listItems({ kind: 'api_key' }); @@ -252,6 +296,59 @@ export class AccountManager { await this.vault.deleteItemForever(itemId); } + // ─── principals ─────────────────────────────────────────────────────────── + + /** + * The account's scoped sub-identities, straight from the server — nothing + * about a principal is secret, and a stale local copy would be worse than + * none, so this is not cached in the vault. + */ + async listPrincipals(accountItemId: string): Promise { + const fields = await this.readFields(accountItemId); + const token = await this.requireToken(accountItemId); + return this.createClient({ + endpoint: fields[ACCOUNT_FIELDS.endpoint], + token, + }).listPrincipals(); + } + + /** + * Create a principal an API key can then be minted as. A principal is a + * narrowing of its owner: it can be read-only, restricted per scope, and + * allowed to skip step-up, but it can never reach further than the human. + */ + async createPrincipal( + accountItemId: string, + options: CreatePrincipalOptions, + proof?: StepUpProof + ): Promise { + const fields = await this.readFields(accountItemId); + const token = await this.requireToken(accountItemId); + return this.withStepUp( + accountItemId, + fields[ACCOUNT_FIELDS.endpoint], + token, + proof, + (client) => client.createPrincipal(options) + ); + } + + async deletePrincipal( + accountItemId: string, + principalId: string, + proof?: StepUpProof + ): Promise { + const fields = await this.readFields(accountItemId); + const token = await this.requireToken(accountItemId); + await this.withStepUp( + accountItemId, + fields[ACCOUNT_FIELDS.endpoint], + token, + proof, + (client) => client.deletePrincipal(principalId) + ); + } + // ─── internals ──────────────────────────────────────────────────────────── /** @@ -399,6 +496,9 @@ export class AccountManager { keyId: fields[KEY_FIELDS.keyId] ?? '', name: item.title, expiresAt: fields[KEY_FIELDS.expiresAt] ?? null, + databaseId: fields[KEY_FIELDS.databaseId] ?? null, + principalId: fields[KEY_FIELDS.principalId] ?? null, + orgId: fields[KEY_FIELDS.orgId] ?? null, }; } diff --git a/packages/accounts/src/types.ts b/packages/accounts/src/types.ts index 2b9ba30..0183db4 100644 --- a/packages/accounts/src/types.ts +++ b/packages/accounts/src/types.ts @@ -24,6 +24,12 @@ export interface ApiKeyRecord { keyId: string; name: string; expiresAt: string | null; + /** The provisioned database this key is the data-plane token for, if any. */ + databaseId: string | null; + /** The scoped sub-identity this key acts as, if it was minted for one. */ + principalId: string | null; + /** The organization it is scoped to, for an org key. */ + orgId: string | null; } /** What a sign-in or sign-up returns before it is written to the vault. */ @@ -52,4 +58,51 @@ export interface CreateApiKeyOptions { name: string; expiresIn?: KeyLifetime; accessLevel?: string; + /** Tag the key as this database's data-plane token, for a harness host. */ + databaseId?: string; + /** Mint the key *as* this principal, so it carries the principal's scope. */ + principalId?: string; + /** Mint an org key, billed and scoped to this organization. */ + orgId?: string; +} + +/** + * A per-scope narrowing of a principal. No row for a scope means the principal + * simply inherits its owner there — an override can only take access away. + */ +export interface PrincipalScope { + /** The scope level (membership type) this row restricts. */ + membershipType: number; + /** Bitmask AND-ed with the owner's permissions during the SPRT cascade. */ + allowedMask: string | null; + isActive: boolean; + isReadOnly: boolean; + useAdminOwner: boolean; +} + +/** + * A scoped sub-identity — what an API key or an agent actually acts as. It is + * owned by a human account and can never exceed that human's permissions. + */ +export interface PrincipalRecord { + principalId: string; + name: string; + ownerId: string | null; + isReadOnly: boolean; + bypassStepUp: boolean; + useAdminOwner: boolean; + /** Organizations (or other entities) this principal is scoped to. */ + entityIds: string[]; + scopes: PrincipalScope[]; +} + +export interface CreatePrincipalOptions { + name: string; + /** The organization to scope it to. */ + orgId: string; + isReadOnly?: boolean; + /** Let it skip MFA step-up — the point of a CI identity. */ + bypassStepUp?: boolean; + /** Inherit the owner's admin rights within the scope. */ + useAdminOwner?: boolean; } diff --git a/packages/cli/__tests__/account.test.ts b/packages/cli/__tests__/account.test.ts index f282b8b..4a5539e 100644 --- a/packages/cli/__tests__/account.test.ts +++ b/packages/cli/__tests__/account.test.ts @@ -95,6 +95,37 @@ describe('dcrypt account', () => { expect(stderr()).toContain('no account in the vault'); }); + it('documents the harness token and principal surface', async () => { + expect(await run('account help')).toBe(0); + expect(stdout()).toContain('token [email]'); + expect(stdout()).toContain('principal create '); + expect(stdout()).toContain('--database '); + }); + + it('serves no bearer from an empty vault', async () => { + const pass = file('pass.txt', 'a strong master password'); + expect(await run(`account token --passphrase-file ${pass} --kdf ${FAST_KDF}`)).toBe( + EXIT.notFound + ); + expect(stderr()).toContain('exactly one account'); + }); + + it('needs an org for a principal', async () => { + const pass = file('pass.txt', 'a strong master password'); + expect( + await run(`account principal create ci --passphrase-file ${pass} --kdf ${FAST_KDF}`) + ).toBe(EXIT.usage); + expect(stderr()).toContain('--org is required'); + }); + + it('says which key it cannot tag', async () => { + const pass = file('pass.txt', 'a strong master password'); + expect( + await run(`account key assign ci db-1 --passphrase-file ${pass} --kdf ${FAST_KDF}`) + ).toBe(EXIT.notFound); + expect(stderr()).toContain('no API key "ci"'); + }); + it('says which account it cannot find', async () => { const pass = file('pass.txt', 'a strong master password'); expect( diff --git a/packages/cli/src/commands/account.ts b/packages/cli/src/commands/account.ts index 6c4bb39..e4bede4 100644 --- a/packages/cli/src/commands/account.ts +++ b/packages/cli/src/commands/account.ts @@ -3,8 +3,10 @@ import { AccountRecord, ApiKeyRecord, KeyLifetime, + PrincipalRecord, StepUpProof, StepUpRequiredError, + VaultCredentials, } from '@decryption/accounts'; import { Vault } from '@decryption/vault'; import { readFileSync } from 'fs'; @@ -37,12 +39,22 @@ Subcommands: key create Mint an API key for --account key reveal Print an API key secret key revoke Revoke server-side, then delete the local copy + key assign Serve this key as that database's data-plane token + token [email] Print the bearer a harness would be given + principal list [email] Scoped sub-identities, with their scopes and masks + principal create Create one scoped to --org + principal delete Remove one server-side Options: --endpoint Auth endpoint (or DCRYPT_AUTH_ENDPOINT) --account Which account a key command applies to --expires-days Lifetime for a new key (default: no expiry) --access-level Access level for a new key + --database Tag a new key as that database's data-plane token + --principal Mint the key as this principal, not as you + --org Organization for a principal, or for an org key + --read-only The principal may only read + --bypass-step-up The principal may skip MFA step-up (for CI) --password-file Read the account password from a file --password-stdin Read the account password from stdin --json Machine-readable output @@ -54,6 +66,9 @@ Examples: dcrypt account key create ci --account dev@example.com --expires-days 30 dcrypt account link-code dev@example.com "Constructive dev" dcrypt account key reveal ci + dcrypt account token dev@example.com + dcrypt account principal create ci-deploy --org --read-only + dcrypt account key create ci --principal --org `; /** @@ -155,6 +170,25 @@ const findAccount = async ( return match; }; +/** + * The account a command applies to: the one named, or the only one there is. + * Never a guess when the vault holds several. + */ +const resolveAccount = async ( + accounts: AccountManager, + ref: string | undefined +): Promise => { + if (ref) return findAccount(accounts, ref); + const all = await accounts.listAccounts(); + if (all.length === 0) { + throw new CliError('no account in the vault — sign in first', EXIT.notFound); + } + if (all.length > 1) { + throw new CliError('name an account: the vault holds more than one'); + } + return all[0]; +}; + const findKey = async ( accounts: AccountManager, ref: string @@ -322,6 +356,9 @@ const withStepUp = async ( } }; +const text = (value: unknown): string | undefined => + typeof value === 'string' && value.length ? value : undefined; + const lifetime = (argv: ParsedArgs): KeyLifetime | undefined => { const raw = argv['expires-days'] ?? argv.expiresDays; if (raw === undefined) return undefined; @@ -351,6 +388,9 @@ const keyCreate = async (argv: ParsedArgs, prompter: Inquirerer): Promise name: first, expiresIn: lifetime(newArgv), accessLevel: typeof accessLevel === 'string' ? accessLevel : undefined, + databaseId: text(newArgv.database), + principalId: text(newArgv.principal), + orgId: text(newArgv.org), }; const key = await withStepUp(prompter, (proof) => accounts.createApiKey(account.itemId, request, proof) @@ -384,6 +424,172 @@ const keyRevoke = async (argv: ParsedArgs, prompter: Inquirerer): Promise }); }; +/** + * Say that an existing key is a database's data-plane token, which is what a + * harness host asks for by database id. + */ +const keyAssign = async (argv: ParsedArgs, prompter: Inquirerer): Promise => { + const { first, newArgv } = takeFirst(argv); + const { first: databaseId, newArgv: rest } = takeFirst(newArgv); + if (!first) throw new CliError('a key name is required'); + if (!databaseId) throw new CliError('a database id is required'); + await withVault(rest, prompter, async (accounts) => { + const key = await findKey(accounts, first); + await accounts.assignKeyToDatabase(key.itemId, databaseId); + emit( + rest, + { itemId: key.itemId, databaseId }, + () => `"${key.name}" is now the data-plane token for ${databaseId}` + ); + }); +}; + +/** + * The bearer a harness host would be handed. Printing a live token is the + * point of the command, so it goes to stdout alone and nowhere else — but the + * vault stays the only place it is stored. + */ +const token = async (argv: ParsedArgs, prompter: Inquirerer): Promise => { + const { first, newArgv } = takeFirst(argv); + const databaseRef = newArgv.database; + await withVault(newArgv, prompter, async (accounts) => { + const accountItemId = first + ? (await findAccount(accounts, first)).itemId + : undefined; + const credentials = new VaultCredentials(accounts, { accountItemId }); + + if (typeof databaseRef === 'string' && databaseRef.length) { + const result = await credentials.dataToken(databaseRef); + if (!result.token) { + throw new CliError( + `no key in the vault is the data-plane token for ${databaseRef} — tag one with "dcrypt account key assign ${databaseRef}"`, + EXIT.notFound + ); + } + emit(newArgv, result, () => result.token as string); + return; + } + + const bearer = await credentials.accountBearer(); + if (!bearer) { + throw new CliError( + first + ? `${first} is signed out — sign in again first` + : 'name an account: a bearer is only served when exactly one account is signed in', + EXIT.notFound + ); + } + emit(newArgv, { accountBearer: bearer }, () => bearer); + }); +}; + +/** + * A principal's reach: the entities it touches, and the per-scope overrides + * that narrow it. No override row means it simply inherits its owner there, + * which is worth saying out loud rather than rendering an empty list. + */ +const describePrincipal = (principal: PrincipalRecord): string => { + const flags = [ + principal.isReadOnly ? 'read-only' : null, + principal.bypassStepUp ? 'skips step-up' : null, + principal.useAdminOwner ? "inherits owner's admin" : null, + ].filter(Boolean); + const scopes = principal.scopes.length + ? principal.scopes + .map( + (scope) => + ` scope ${scope.membershipType}: ${ + scope.isActive ? 'active' : 'disabled' + }${scope.isReadOnly ? ', read-only' : ''}, mask ${ + scope.allowedMask ?? 'inherited' + }` + ) + .join('\n') + : ' (no overrides — inherits the owner everywhere it is scoped)'; + return [ + `${principal.name.padEnd(24)} ${principal.principalId}`, + ` ${flags.join(', ') || 'no flags'}`, + ` entities: ${principal.entityIds.join(', ') || '(none)'}`, + scopes, + ].join('\n'); +}; + +const principalList = async ( + argv: ParsedArgs, + prompter: Inquirerer +): Promise => { + const { first, newArgv } = takeFirst(argv); + await withVault(newArgv, prompter, async (accounts) => { + const account = await resolveAccount(accounts, first); + const principals = await accounts.listPrincipals(account.itemId); + emit( + newArgv, + principals, + () => principals.map(describePrincipal).join('\n\n') || '(no principals)' + ); + }); +}; + +const principalCreate = async ( + argv: ParsedArgs, + prompter: Inquirerer +): Promise => { + const { first, newArgv } = takeFirst(argv); + if (!first) throw new CliError('a principal name is required'); + const orgId = text(newArgv.org); + if (!orgId) throw new CliError('--org is required'); + + await withVault(newArgv, prompter, async (accounts) => { + const account = await resolveAccount(accounts, text(newArgv.account)); + const principalId = await withStepUp(prompter, (proof) => + accounts.createPrincipal( + account.itemId, + { + name: first, + orgId, + isReadOnly: Boolean(newArgv['read-only'] ?? newArgv.readOnly), + bypassStepUp: Boolean(newArgv['bypass-step-up'] ?? newArgv.bypassStepUp), + }, + proof + ) + ); + emit( + newArgv, + { principalId }, + () => `created "${first}" (${principalId}) — mint keys as it with --principal ${principalId}` + ); + }); +}; + +const principalDelete = async ( + argv: ParsedArgs, + prompter: Inquirerer +): Promise => { + const { first, newArgv } = takeFirst(argv); + if (!first) throw new CliError('a principal id is required'); + await withVault(newArgv, prompter, async (accounts) => { + const account = await resolveAccount(accounts, text(newArgv.account)); + await withStepUp(prompter, (proof) => + accounts.deletePrincipal(account.itemId, first, proof) + ); + emit(newArgv, { principalId: first }, () => `removed ${first}`); + }); +}; + +const principalCommand = async ( + argv: ParsedArgs, + prompter: Inquirerer +): Promise => + runSubcommand(argv, prompter, { + name: 'account principal', + usage: accountUsage, + handlers: { + list: principalList, + create: principalCreate, + delete: principalDelete, + }, + }); + const keyCommand = async (argv: ParsedArgs, prompter: Inquirerer): Promise => runSubcommand(argv, prompter, { name: 'account key', @@ -393,6 +599,7 @@ const keyCommand = async (argv: ParsedArgs, prompter: Inquirerer): Promise create: keyCreate, reveal: keyReveal, revoke: keyRevoke, + assign: keyAssign, }, }); @@ -412,5 +619,7 @@ export const accountCommand = async ( 'link-code': linkCode, 'unlink-code': unlinkCode, key: keyCommand, + principal: principalCommand, + token, }, });