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, }, });