diff --git a/apps/desktop/__tests__/principal.test.ts b/apps/desktop/__tests__/principal.test.ts new file mode 100644 index 0000000..2462f29 --- /dev/null +++ b/apps/desktop/__tests__/principal.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import type { PrincipalRecord } from '../src/shared/api'; +import { principalReach } from '../src/shared/principal'; + +const principal = (overrides: Partial = {}): PrincipalRecord => ({ + principalId: 'principal-1', + name: 'ci-deploy', + ownerId: 'user-1', + isReadOnly: false, + bypassStepUp: false, + useAdminOwner: true, + entityIds: ['org-1'], + scopes: [], + ...overrides, +}); + +describe('principalReach', () => { + it('says it inherits, rather than showing nothing, when no scope is overridden', () => { + expect(principalReach(principal())).toBe('inherits you everywhere it is scoped'); + }); + + it('names the restrictions it does carry', () => { + const text = principalReach(principal({ isReadOnly: true, bypassStepUp: true })); + expect(text).toContain('read-only'); + expect(text).toContain('skips step-up'); + }); + + it('shows a scope mask, and says when the scope is switched off', () => { + const text = principalReach( + principal({ + scopes: [ + { + membershipType: 2, + allowedMask: '0011', + isActive: false, + isReadOnly: true, + useAdminOwner: false, + }, + ], + }) + ); + expect(text).toContain('scope 2'); + expect(text).toContain('disabled'); + expect(text).toContain('mask 0011'); + }); + + it('calls an absent mask inherited, because it is not an empty one', () => { + const text = principalReach( + principal({ + scopes: [ + { + membershipType: 1, + allowedMask: null, + isActive: true, + isReadOnly: false, + useAdminOwner: true, + }, + ], + }) + ); + expect(text).toContain('mask inherited'); + }); +}); diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 3951d0d..44b6154 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -10,6 +10,7 @@ import * as path from 'path'; import { CHANNELS, CreateKeyRequest, + CreatePrincipalRequest, FieldPurpose, ItemKind, SignInRequest, @@ -242,6 +243,11 @@ export const registerIpc = (service: VaultService): void => { expiresIn: days === undefined ? undefined : { days: assertInt(days, 1, 3650) }, accessLevel: request?.accessLevel === undefined ? undefined : assertString(request.accessLevel), + principalId: + request?.principalId === undefined ? undefined : assertString(request.principalId), + orgId: request?.orgId === undefined ? undefined : assertString(request.orgId), + databaseId: + request?.databaseId === undefined ? undefined : assertString(request.databaseId), }, proof(stepUp) ); @@ -256,6 +262,40 @@ export const registerIpc = (service: VaultService): void => { await accounts().revokeApiKey(assertString(itemId), proof(stepUp)); service.scheduleSave(); }); + handle( + CHANNELS.accountsAssignKeyDatabase, + async (itemId: string, databaseId: string) => { + await accounts().assignKeyToDatabase(assertString(itemId), assertString(databaseId)); + service.scheduleSave(); + } + ); + handle(CHANNELS.accountsPrincipals, (accountItemId: string) => + accounts().listPrincipals(assertString(accountItemId)) + ); + handle( + CHANNELS.accountsCreatePrincipal, + (accountItemId: string, request: CreatePrincipalRequest, stepUp?: StepUpProof) => + accounts().createPrincipal( + assertString(accountItemId), + { + name: assertString(request?.name), + orgId: assertString(request?.orgId), + isReadOnly: Boolean(request?.isReadOnly), + bypassStepUp: Boolean(request?.bypassStepUp), + }, + proof(stepUp) + ) + ); + handle( + CHANNELS.accountsDeletePrincipal, + async (accountItemId: string, principalId: string, stepUp?: StepUpProof) => { + await accounts().deletePrincipal( + assertString(accountItemId), + assertString(principalId), + proof(stepUp) + ); + } + ); handle(CHANNELS.accountsLinkTotp, async (accountItemId: string, totpItemId: string) => { await accounts().linkTotp(assertString(accountItemId), assertString(totpItemId)); service.scheduleSave(); diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index cbf952c..ce1ec12 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -63,6 +63,13 @@ const api: DcryptApi & { invoke(CHANNELS.accountsCreateKey, accountItemId, request, stepUp), revealKey: (itemId) => invoke(CHANNELS.accountsRevealKey, itemId), revokeKey: (itemId, stepUp) => invoke(CHANNELS.accountsRevokeKey, itemId, stepUp), + assignKeyToDatabase: (itemId, databaseId) => + invoke(CHANNELS.accountsAssignKeyDatabase, itemId, databaseId), + principals: (accountItemId) => invoke(CHANNELS.accountsPrincipals, accountItemId), + createPrincipal: (accountItemId, request, stepUp) => + invoke(CHANNELS.accountsCreatePrincipal, accountItemId, request, stepUp), + deletePrincipal: (accountItemId, principalId, stepUp) => + invoke(CHANNELS.accountsDeletePrincipal, accountItemId, principalId, stepUp), linkTotp: (accountItemId, totpItemId) => invoke(CHANNELS.accountsLinkTotp, accountItemId, totpItemId), unlinkTotp: (accountItemId) => invoke(CHANNELS.accountsUnlinkTotp, accountItemId), diff --git a/apps/desktop/src/renderer/src/screens/AccountsScreen.tsx b/apps/desktop/src/renderer/src/screens/AccountsScreen.tsx index 43f40ae..ea47dec 100644 --- a/apps/desktop/src/renderer/src/screens/AccountsScreen.tsx +++ b/apps/desktop/src/renderer/src/screens/AccountsScreen.tsx @@ -20,10 +20,12 @@ import { Label } from '@constructive-io/ui/label'; import { Separator } from '@constructive-io/ui/separator'; import { Copy, + Database, KeyRound, LogIn, LogOut, Plus, + ShieldUser, Timer, Trash2, UserPlus, @@ -34,9 +36,11 @@ import { toast } from 'sonner'; import type { AccountRecord, ApiKeyRecord, + PrincipalRecord, StepUpProof, TotpEntry, } from '../../../shared/api'; +import { principalReach } from '../../../shared/principal'; import { StepUpKind, stepUpKind, @@ -70,6 +74,18 @@ export const AccountsScreen = () => { const [keyFor, setKeyFor] = useState(null); const [keyName, setKeyName] = useState(''); const [keyDays, setKeyDays] = useState(''); + const [keyPrincipal, setKeyPrincipal] = useState(null); + const [keyDatabase, setKeyDatabase] = useState(''); + + const [tagFor, setTagFor] = useState(null); + const [tagValue, setTagValue] = useState(''); + + const [principals, setPrincipals] = useState>({}); + const [principalFor, setPrincipalFor] = useState(null); + const [principalName, setPrincipalName] = useState(''); + const [principalOrg, setPrincipalOrg] = useState(''); + const [principalReadOnly, setPrincipalReadOnly] = useState(true); + const [principalBypass, setPrincipalBypass] = useState(false); const [held, setHeld] = useState(null); const [proofValue, setProofValue] = useState(''); @@ -87,6 +103,19 @@ export const AccountsScreen = () => { setAccounts(nextAccounts); setKeys(nextKeys); setCodes(nextCodes); + + // principals live on the server; a signed-out account simply has none to show + const signedIn = nextAccounts.filter((account) => account.signedIn); + const fetched = await Promise.all( + signedIn.map(async (account) => { + try { + return [account.itemId, await dcrypt.accounts.principals(account.itemId)] as const; + } catch { + return [account.itemId, []] as const; + } + }) + ); + setPrincipals(Object.fromEntries(fetched)); } catch { // vault locked mid-refresh } @@ -150,13 +179,41 @@ export const AccountsScreen = () => { toast.error('Expiry must be a whole number of days'); return; } - const request = { name: keyName.trim(), expiresDays: days }; + const request = { + name: keyName.trim(), + expiresDays: days, + principalId: keyPrincipal?.principalId, + orgId: keyPrincipal?.entityIds[0], + databaseId: keyDatabase.trim() || undefined, + }; await run(async (proof) => { const key = await dcrypt.accounts.createKey(account.itemId, request, proof); setKeyName(''); setKeyDays(''); + setKeyPrincipal(null); + setKeyDatabase(''); setKeyFor(null); - return `Created "${key.name}" — the secret is in your vault`; + return keyPrincipal + ? `Created "${key.name}" as ${keyPrincipal.name} — the secret is in your vault` + : `Created "${key.name}" — the secret is in your vault`; + }); + }; + + const createPrincipal = async (): Promise => { + const account = principalFor; + if (!account) return; + const request = { + name: principalName.trim(), + orgId: principalOrg.trim(), + isReadOnly: principalReadOnly, + bypassStepUp: principalBypass, + }; + await run(async (proof) => { + await dcrypt.accounts.createPrincipal(account.itemId, request, proof); + setPrincipalName(''); + setPrincipalOrg(''); + setPrincipalFor(null); + return `Created ${request.name} — mint a key as it to give it credentials`; }); }; @@ -200,6 +257,11 @@ export const AccountsScreen = () => { {accounts.map((account) => { const accountKeys = keys.filter((key) => key.accountItemId === account.itemId); + const accountPrincipals = principals[account.itemId] ?? []; + const mintedAs = (key: ApiKeyRecord): string => + accountPrincipals.find( + (principal) => principal.principalId === key.principalId + )?.name ?? 'a principal'; return ( @@ -223,6 +285,14 @@ export const AccountsScreen = () => { > New API key + + + ))} + {accountKeys.length > 0 && } {accountKeys.map((key) => (
{key.name} {expiry(key.expiresAt)} + {key.principalId && ( + + as {mintedAs(key)} + + )} + {key.databaseId && ( + + {key.databaseId} + + )}
+
+ {keyFor && (principals[keyFor.itemId] ?? []).length > 0 && ( +
+ +
+ + {(principals[keyFor.itemId] ?? []).map((principal) => ( + + ))} +
+

+ A key minted as a principal carries that principal's scope, not a + copy of your own access. +

+
+ )} +
+ + setKeyDatabase(e.target.value)} + placeholder="database id" + className="font-mono" + /> +

+ Tag the key as that database's data-plane token, and dcrypt will serve + it to a harness asking for one. +

+
+ + + + + + !open && setPrincipalFor(null)} + > + + + New principal + + A principal is a scoped sub-identity for keys and agents. It is owned by + this account and can only ever narrow it — never reach further than you do. + + + +
+ + setPrincipalName(e.target.value)} + placeholder="ci-deploy" + /> +
+
+ + setPrincipalOrg(e.target.value)} + placeholder="org id" + className="font-mono" + /> +

+ The organization it is scoped to. +

+
+
+ + +
+

+ Skipping step-up is what lets an unattended job use its key without a + one-time code, so pair it with read-only unless the job needs to write. +

+
+ + + + +
+
+ !open && setLinkFor(null)}> diff --git a/apps/desktop/src/shared/api.ts b/apps/desktop/src/shared/api.ts index 213936a..9861bc1 100644 --- a/apps/desktop/src/shared/api.ts +++ b/apps/desktop/src/shared/api.ts @@ -1,4 +1,9 @@ -import type { AccountRecord, ApiKeyRecord, StepUpProof } from '@decryption/accounts'; +import type { + AccountRecord, + ApiKeyRecord, + PrincipalRecord, + StepUpProof, +} from '@decryption/accounts'; import type { AuditEntry, FieldPurpose, @@ -15,6 +20,7 @@ export type { AuditEntry, FieldPurpose, ItemKind, + PrincipalRecord, StepUpProof, VaultFieldMeta, VaultFolder, @@ -87,6 +93,19 @@ export interface CreateKeyRequest { /** Lifetime in whole days; omitted means the server's default. */ expiresDays?: number; accessLevel?: string; + /** Mint the key as this principal, so it carries the principal's scope. */ + principalId?: string; + /** Mint an org key rather than a personal one. */ + orgId?: string; + /** Tag the key as this database's data-plane token, for a harness host. */ + databaseId?: string; +} + +export interface CreatePrincipalRequest { + name: string; + orgId: string; + isReadOnly?: boolean; + bypassStepUp?: boolean; } /** What a rebuild carried across, so the UI can show it was not a no-op. */ @@ -169,6 +188,20 @@ export interface DcryptApi { /** Reads the secret back out of the vault, on demand only. */ revealKey(itemId: string): Promise; revokeKey(itemId: string, stepUp?: StepUpProof): Promise; + /** Tag a stored key as a database's data-plane token; the secret is untouched. */ + assignKeyToDatabase(itemId: string, databaseId: string): Promise; + /** Read from the server on demand — permissions are never mirrored locally. */ + principals(accountItemId: string): Promise; + createPrincipal( + accountItemId: string, + request: CreatePrincipalRequest, + stepUp?: StepUpProof + ): Promise; + deletePrincipal( + accountItemId: string, + principalId: string, + stepUp?: StepUpProof + ): Promise; /** Point an account at a vault code that then answers its MFA step-ups. */ linkTotp(accountItemId: string, totpItemId: string): Promise; unlinkTotp(accountItemId: string): Promise; @@ -255,6 +288,10 @@ export const CHANNELS = { accountsCreateKey: 'accounts:create-key', accountsRevealKey: 'accounts:reveal-key', accountsRevokeKey: 'accounts:revoke-key', + accountsAssignKeyDatabase: 'accounts:assign-key-database', + accountsPrincipals: 'accounts:principals', + accountsCreatePrincipal: 'accounts:create-principal', + accountsDeletePrincipal: 'accounts:delete-principal', accountsLinkTotp: 'accounts:link-totp', accountsUnlinkTotp: 'accounts:unlink-totp', auditLog: 'audit:log', diff --git a/apps/desktop/src/shared/principal.ts b/apps/desktop/src/shared/principal.ts new file mode 100644 index 0000000..0140be0 --- /dev/null +++ b/apps/desktop/src/shared/principal.ts @@ -0,0 +1,28 @@ +import type { PrincipalRecord } from './api'; + +/** + * What a principal may do, in words. + * + * A scope carrying no override row inherits its owner outright, which has to be + * said rather than rendered as an empty list: "no restrictions recorded" and + * "no access" look identical otherwise, and they are opposites. + */ +export const principalReach = (principal: PrincipalRecord): string => { + const flags = [ + principal.isReadOnly ? 'read-only' : null, + principal.bypassStepUp ? 'skips step-up' : null, + ].filter((flag): flag is string => flag !== null); + + const scopes = principal.scopes.length + ? principal.scopes + .map( + (scope) => + `scope ${scope.membershipType}: ${scope.isActive ? '' : 'disabled, '}mask ${ + scope.allowedMask ?? 'inherited' + }` + ) + .join(' · ') + : 'inherits you everywhere it is scoped'; + + return [...flags, scopes].join(' · '); +};