Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions apps/desktop/__tests__/principal.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';

import type { PrincipalRecord } from '../src/shared/api';
import { principalReach } from '../src/shared/principal';
import type { ApiKeyRecord, PrincipalRecord } from '../src/shared/api';
import { knownOrgIds, principalReach } from '../src/shared/principal';

const principal = (overrides: Partial<PrincipalRecord> = {}): PrincipalRecord => ({
principalId: 'principal-1',
Expand All @@ -15,6 +15,43 @@ const principal = (overrides: Partial<PrincipalRecord> = {}): PrincipalRecord =>
...overrides,
});

const key = (overrides: Partial<ApiKeyRecord> = {}): ApiKeyRecord => ({
itemId: 'item-1',
accountItemId: 'account-1',
endpoint: 'http://auth.localhost:3000/graphql',
keyId: 'key-1',
name: 'ci',
expiresAt: null,
databaseId: null,
principalId: null,
orgId: null,
...overrides,
});

describe('knownOrgIds', () => {
it('gathers the organizations already scoped, from principals and org keys alike', () => {
expect(
knownOrgIds(
[principal({ entityIds: ['org-b'] })],
[key({ orgId: 'org-a' }), key({ itemId: 'item-2' })]
)
).toEqual(['org-a', 'org-b']);
});

it('offers each organization once, however many things are scoped to it', () => {
expect(
knownOrgIds(
[principal({ entityIds: ['org-a'] }), principal({ entityIds: ['org-a'] })],
[key({ orgId: 'org-a' })]
)
).toEqual(['org-a']);
});

it('is empty for an account that has scoped nothing, so the id must be typed', () => {
expect(knownOrgIds([principal({ entityIds: [] })], [key()])).toEqual([]);
});
});

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');
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ export const registerIpc = (service: VaultService): void => {
assertString(accountItemId),
{
name: assertString(request?.name),
orgId: assertString(request?.orgId),
orgId: request?.orgId === undefined ? undefined : assertString(request.orgId),
isReadOnly: Boolean(request?.isReadOnly),
bypassStepUp: Boolean(request?.bypassStepUp),
},
Expand Down
98 changes: 86 additions & 12 deletions apps/desktop/src/renderer/src/screens/AccountsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ import {
} from '@constructive-io/ui/dialog';
import { Input } from '@constructive-io/ui/input';
import { Label } from '@constructive-io/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@constructive-io/ui/select';
import { Separator } from '@constructive-io/ui/separator';
import {
Copy,
Expand All @@ -40,7 +47,7 @@ import type {
StepUpProof,
TotpEntry,
} from '../../../shared/api';
import { principalReach } from '../../../shared/principal';
import { knownOrgIds, principalReach } from '../../../shared/principal';
import {
StepUpKind,
stepUpKind,
Expand All @@ -55,6 +62,14 @@ const message = (error: unknown): string =>
const expiry = (iso: string | null): string =>
iso ? `expires ${new Date(iso).toLocaleString()}` : 'no expiry';

/**
* Personal reaches wherever its owner does; organization narrows it to one org.
* A third choice — "another organization" — only picks the id, not the scope.
*/
type PrincipalScope = 'personal' | 'organization';

const OTHER_ORG = 'other';

/** A request the server refused until a factor is re-proved, kept to replay. */
interface HeldRequest {
kind: StepUpKind;
Expand Down Expand Up @@ -83,6 +98,8 @@ export const AccountsScreen = () => {
const [principals, setPrincipals] = useState<Record<string, PrincipalRecord[]>>({});
const [principalFor, setPrincipalFor] = useState<AccountRecord | null>(null);
const [principalName, setPrincipalName] = useState('');
const [principalScope, setPrincipalScope] = useState<PrincipalScope>('personal');
const [principalOrgChoice, setPrincipalOrgChoice] = useState('');
const [principalOrg, setPrincipalOrg] = useState('');
const [principalReadOnly, setPrincipalReadOnly] = useState(true);
const [principalBypass, setPrincipalBypass] = useState(false);
Expand Down Expand Up @@ -199,18 +216,30 @@ export const AccountsScreen = () => {
});
};

const orgOptions = principalFor
? knownOrgIds(
principals[principalFor.itemId] ?? [],
keys.filter((key) => key.accountItemId === principalFor.itemId)
)
: [];
const chosenOrgId =
principalOrgChoice === OTHER_ORG ? principalOrg.trim() : principalOrgChoice;

const createPrincipal = async (): Promise<void> => {
const account = principalFor;
if (!account) return;
const request = {
name: principalName.trim(),
orgId: principalOrg.trim(),
// a personal principal carries no org at all, rather than an empty one
...(principalScope === 'organization' ? { orgId: chosenOrgId } : {}),
isReadOnly: principalReadOnly,
bypassStepUp: principalBypass,
};
await run(async (proof) => {
await dcrypt.accounts.createPrincipal(account.itemId, request, proof);
setPrincipalName('');
setPrincipalScope('personal');
setPrincipalOrgChoice('');
setPrincipalOrg('');
setPrincipalFor(null);
return `Created ${request.name} — mint a key as it to give it credentials`;
Expand Down Expand Up @@ -650,18 +679,59 @@ export const AccountsScreen = () => {
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="principal-org">Organization</Label>
<Input
id="principal-org"
value={principalOrg}
onChange={(e) => setPrincipalOrg(e.target.value)}
placeholder="org id"
className="font-mono"
/>
<Label htmlFor="principal-scope">Scope</Label>
<Select
value={principalScope}
onValueChange={(value) => setPrincipalScope(value as PrincipalScope)}
>
<SelectTrigger id="principal-scope">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="personal">Personal</SelectItem>
<SelectItem value="organization">Organization</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
The organization it is scoped to.
{principalScope === 'personal'
? 'Owned by you, reaching wherever you do — the shape an unattended job of your own wants.'
: 'Narrowed to one organization you work in.'}
</p>
</div>
{principalScope === 'organization' && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="principal-org">Organization</Label>
<Select
value={principalOrgChoice}
onValueChange={setPrincipalOrgChoice}
>
<SelectTrigger id="principal-org">
<SelectValue placeholder="Choose an organization" />
</SelectTrigger>
<SelectContent>
{orgOptions.map((orgId) => (
<SelectItem key={orgId} value={orgId} className="font-mono">
{orgId}
</SelectItem>
))}
<SelectItem value={OTHER_ORG}>Another organization…</SelectItem>
</SelectContent>
</Select>
{principalOrgChoice === OTHER_ORG && (
<Input
value={principalOrg}
onChange={(e) => setPrincipalOrg(e.target.value)}
placeholder="org id"
className="font-mono"
/>
)}
<p className="text-xs text-muted-foreground">
{orgOptions.length
? 'Organizations this account has already scoped a principal or key to.'
: 'Nothing scoped yet from this account, so the id has to be typed once.'}
</p>
</div>
)}
<div className="flex gap-2">
<Button
type="button"
Expand Down Expand Up @@ -690,7 +760,11 @@ export const AccountsScreen = () => {
Cancel
</Button>
<Button
disabled={busy || !principalName.trim() || !principalOrg.trim()}
disabled={
busy ||
!principalName.trim() ||
(principalScope === 'organization' && !chosenOrgId)
}
onClick={createPrincipal}
>
{busy ? 'Creating…' : 'Create principal'}
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/shared/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ export interface CreateKeyRequest {

export interface CreatePrincipalRequest {
name: string;
orgId: string;
/** Omitted for a personal principal, which reaches wherever its owner does. */
orgId?: string;
isReadOnly?: boolean;
bypassStepUp?: boolean;
}
Expand Down
25 changes: 24 additions & 1 deletion apps/desktop/src/shared/principal.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,27 @@
import type { PrincipalRecord } from './api';
import type { ApiKeyRecord, PrincipalRecord } from './api';

/**
* The organizations this account is already known to work in, gathered from
* what it has scoped before: a principal's entities and an org key's org.
*
* The auth plane has no "my organizations" query — memberships live behind the
* admin surface, which a signed-in user's token does not reach — so an id the
* account has demonstrably used is the honest list to offer, and typing one in
* stays possible for the first ever principal in an org.
*/
export const knownOrgIds = (
principals: PrincipalRecord[],
keys: ApiKeyRecord[]
): string[] => {
const ids = new Set<string>();
for (const principal of principals) {
for (const entityId of principal.entityIds) ids.add(entityId);
}
for (const key of keys) {
if (key.orgId) ids.add(key.orgId);
}
return [...ids].sort();
};

/**
* What a principal may do, in words.
Expand Down
13 changes: 12 additions & 1 deletion packages/accounts/__tests__/accounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ class FakeServer {
isReadOnly: options.isReadOnly ?? false,
bypassStepUp: options.bypassStepUp ?? false,
useAdminOwner: options.useAdminOwner ?? true,
entityIds: [options.orgId],
entityIds: options.orgId ? [options.orgId] : [],
scopes: [],
};
this.principals.push(principal);
Expand Down Expand Up @@ -535,6 +535,17 @@ describe('principals', () => {
expect((await accounts.listApiKeys())[0].principalId).toBe(principalId);
});

it('creates a personal one, scoped to nothing but its owner', async () => {
const account = await signIn();
const principalId = await accounts.createPrincipal(account.itemId, {
name: 'my-ci',
isReadOnly: true,
});

const [principal] = await accounts.listPrincipals(account.itemId);
expect(principal).toMatchObject({ principalId, name: 'my-ci', entityIds: [] });
});

it('answers a step-up when creating one, like every other sensitive call', async () => {
const account = await signIn();
server.demandStepUp = 'password';
Expand Down
75 changes: 56 additions & 19 deletions packages/accounts/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,40 @@ const PRINCIPAL_SELECT_CORE = {

type PrincipalSelect = typeof PRINCIPAL_SELECT | typeof PRINCIPAL_SELECT_CORE;

/**
* A principal scoped to nothing but its owner — the personal CI identity.
*
* Sent by hand because the generated ORM has no `mutation.createPrincipal`: the
* codegen let the principals table's CRUD create shadow the procedure of the
* same inflected name, so the typed surface offers only the org variant. The
* auth schema itself does expose it, taking the flat input built here.
*/
const createPersonalPrincipal = async (
endpoint: string,
token: string | undefined,
input: {
name: string;
isReadOnly?: boolean;
bypassStepUp?: boolean;
useAdminOwner?: boolean;
}
): Promise<string | null> => {
const adapter = new auth.FetchAdapter(
endpoint,
token ? { Authorization: `Bearer ${token}` } : undefined
);
const result = await adapter.execute<{
createPrincipal: { result: string | null } | null;
}>(
`mutation CreatePrincipal($input: CreatePrincipalInput!) {
createPrincipal(input: $input) { result }
}`,
{ input }
);
if (!result.ok) throw new Error(result.errors.map((e) => e.message).join('; '));
return result.data.createPrincipal?.result ?? null;
};

const rethrow = (operation: string, endpoint: string, error: unknown): never => {
const message = error instanceof Error ? error.message : String(error);
const kind = stepUpKind(message);
Expand Down Expand Up @@ -356,26 +390,29 @@ export const sdkAuthClient: AuthClientFactory = (options) => {
},

async createPrincipal(options) {
const flags = {
isReadOnly: options.isReadOnly,
bypassStepUp: options.bypassStepUp,
// omitted rather than sent as null, so a server that predates
// the field is not asked about it at all
...(options.useAdminOwner === undefined
? {}
: { useAdminOwner: options.useAdminOwner }),
};
try {
const data = await client.mutation
.createOrgPrincipal(
{
input: {
name: options.name,
orgId: options.orgId,
isReadOnly: options.isReadOnly,
bypassStepUp: options.bypassStepUp,
// omitted rather than sent as null, so a server that predates
// the field is not asked about it at all
...(options.useAdminOwner === undefined
? {}
: { useAdminOwner: options.useAdminOwner }),
},
},
{ select: { result: true } }
)
.unwrap();
const principalId = data.createOrgPrincipal?.result;
const principalId = options.orgId
? (
await client.mutation
.createOrgPrincipal(
{ input: { name: options.name, orgId: options.orgId, ...flags } },
{ select: { result: true } }
)
.unwrap()
).createOrgPrincipal?.result
: await createPersonalPrincipal(endpoint, token, {
name: options.name,
...flags,
});
if (!principalId) {
throw new AuthError('createPrincipal', 'the server returned no principal');
}
Expand Down
8 changes: 6 additions & 2 deletions packages/accounts/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,12 @@ export interface PrincipalRecord {

export interface CreatePrincipalOptions {
name: string;
/** The organization to scope it to. */
orgId: string;
/**
* The organization to scope it to. Omitted means a personal principal: one
* that reaches wherever you do, which is what an unattended job of your own
* wants — an org id would only narrow it.
*/
orgId?: string;
isReadOnly?: boolean;
/** Let it skip MFA step-up — the point of a CI identity. */
bypassStepUp?: boolean;
Expand Down
Loading
Loading