Skip to content

Commit eace775

Browse files
authored
Merge pull request #1312 from constructive-io/feat/principal-auth-docs
docs: add constructive-principals skill for application-layer usage
2 parents 03d9e7e + d973a01 commit eace775

2 files changed

Lines changed: 350 additions & 0 deletions

File tree

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
---
2+
name: constructive-principals
3+
description: "Principal identity system at the application layer — ORM, CLI, and hooks usage for creating/managing principals (scoped sub-identities). Covers the dual-claim JWT model, principal lifecycle via SDK, org scoping with principal_entities, and AuthzHumanOnly enforcement. Use when asked to 'create a principal', 'manage API agents', 'scope an API key', 'use principals', or when building principal management features."
4+
metadata:
5+
author: constructive-io
6+
version: "1.0.0"
7+
triggers: "user, model"
8+
---
9+
10+
# Principals — Application Layer
11+
12+
Principals are scoped sub-identities (user type=3) that authenticate via API keys with a subset of their parent human's permissions. This skill covers how to use principals from the ORM, CLI, and React hooks.
13+
14+
For SQL-level internals, see the `constructive-db-principals` skill in the `constructive-db` repo.
15+
16+
## When to Apply
17+
18+
Use this skill when:
19+
- Creating or deleting principals via ORM, CLI, or hooks
20+
- Building UI for principal management
21+
- Scoping a principal to specific orgs via `principal_entities`
22+
- Understanding the dual-claim JWT model at the application layer
23+
24+
## Core Concepts
25+
26+
### Dual-Claim JWT Model
27+
28+
Two JWT claims are always present. For normal human sessions, both are identical:
29+
30+
| Claim | Resolves To | Application Use |
31+
|-------|-------------|-----------------|
32+
| `jwt.claims.user_id` | Always the human | Billing, ownership, peoplestamps |
33+
| `jwt.claims.principal_id` | The principal (or same as user_id for humans) | SPRT permission lookups |
34+
35+
When authenticating with a principal's API key, `user_id` stays the human owner and `principal_id` becomes the principal's identity. All existing ownership checks (`created_by`, `updated_by`, billing) continue to attribute to the human.
36+
37+
### User Types
38+
39+
```
40+
1 = User (human)
41+
2 = Organization
42+
3 = Principal (scoped sub-identity)
43+
```
44+
45+
### AuthzHumanOnly
46+
47+
All principal management mutations (`create_principal`, `delete_principal`) are guarded by `AuthzHumanOnly` — only the owning human can call them. A principal session cannot create or delete other principals.
48+
49+
### Org Scoping
50+
51+
Principals can be restricted to specific orgs via `principal_entities`:
52+
- **No entries** = unrestricted (inherits access to all parent's orgs)
53+
- **1+ entries** = restricted to only those specific orgs
54+
55+
## ORM Usage
56+
57+
### Create a principal
58+
59+
```typescript
60+
const result = await db.createPrincipal({
61+
data: {
62+
name: 'billing-bot',
63+
allowedMask: null, // null = inherit all parent permissions
64+
isReadOnly: false,
65+
bypassStepUp: true,
66+
entityIds: null // null = unrestricted org access
67+
},
68+
select: {
69+
id: true,
70+
userId: true,
71+
name: true,
72+
ownerIdId: true
73+
}
74+
}).execute();
75+
76+
const principal = result.unwrap();
77+
```
78+
79+
### Create a principal scoped to specific orgs
80+
81+
```typescript
82+
const result = await db.createPrincipal({
83+
data: {
84+
name: 'org-a-only-bot',
85+
allowedMask: null,
86+
isReadOnly: true,
87+
bypassStepUp: true,
88+
entityIds: [orgAId, orgBId] // restrict to these orgs
89+
},
90+
select: { id: true, name: true }
91+
}).execute();
92+
```
93+
94+
### Delete a principal
95+
96+
```typescript
97+
const result = await db.deletePrincipal({
98+
data: {
99+
principalId: principalId
100+
},
101+
select: { success: true }
102+
}).execute();
103+
```
104+
105+
### Query principals (owner sees own only)
106+
107+
```typescript
108+
const principals = await db.principal.findMany({
109+
select: {
110+
id: true,
111+
name: true,
112+
userId: true,
113+
allowedMask: true,
114+
isReadOnly: true,
115+
bypassStepUp: true
116+
}
117+
}).execute();
118+
```
119+
120+
### Query principal entity scoping
121+
122+
```typescript
123+
const scoping = await db.principalEntity.findMany({
124+
where: { principalId: principalId },
125+
select: {
126+
id: true,
127+
principalId: true,
128+
entityId: true
129+
}
130+
}).execute();
131+
```
132+
133+
## CLI Usage
134+
135+
### Create a principal
136+
137+
```bash
138+
csdk create-principal \
139+
--name "ci-deploy" \
140+
--is-read-only false \
141+
--bypass-step-up true
142+
```
143+
144+
### Create with org scoping
145+
146+
```bash
147+
csdk create-principal \
148+
--name "org-scoped-bot" \
149+
--entity-ids "<org-uuid-1>,<org-uuid-2>" \
150+
--is-read-only true
151+
```
152+
153+
### Delete a principal
154+
155+
```bash
156+
csdk delete-principal --principal-id "<principal-uuid>"
157+
```
158+
159+
### List principals
160+
161+
```bash
162+
csdk principal list
163+
```
164+
165+
### List principal entities (org scoping)
166+
167+
```bash
168+
csdk principal-entity list --principal-id "<principal-uuid>"
169+
```
170+
171+
## React Hooks Usage
172+
173+
### Create a principal
174+
175+
```typescript
176+
import { useCreatePrincipalMutation } from './hooks';
177+
178+
const { mutate: createPrincipal } = useCreatePrincipalMutation();
179+
180+
createPrincipal({
181+
input: {
182+
name: 'my-bot',
183+
allowedMask: null,
184+
isReadOnly: false,
185+
bypassStepUp: true,
186+
entityIds: null
187+
}
188+
});
189+
```
190+
191+
### Delete a principal
192+
193+
```typescript
194+
import { useDeletePrincipalMutation } from './hooks';
195+
196+
const { mutate: deletePrincipal } = useDeletePrincipalMutation();
197+
198+
deletePrincipal({
199+
input: { principalId: principalId }
200+
});
201+
```
202+
203+
### Query principals
204+
205+
```typescript
206+
import { usePrincipalsQuery } from './hooks';
207+
208+
const { data, isLoading } = usePrincipalsQuery({
209+
selection: {
210+
fields: {
211+
id: true,
212+
name: true,
213+
userId: true,
214+
isReadOnly: true
215+
}
216+
}
217+
});
218+
```
219+
220+
## Error Handling
221+
222+
All principal mutations return discriminated union results. Handle errors with `.unwrap()` or `.unwrapOr()`:
223+
224+
```typescript
225+
const result = await db.createPrincipal({
226+
data: { name: 'bot', allowedMask: null, isReadOnly: false, bypassStepUp: true, entityIds: null },
227+
select: { id: true }
228+
}).execute();
229+
230+
// Throws on error
231+
const principal = result.unwrap();
232+
233+
// Or handle gracefully
234+
const principal = result.unwrapOr(null);
235+
if (!principal) {
236+
// handle error
237+
}
238+
```
239+
240+
### Expected errors
241+
242+
| Error | Cause |
243+
|-------|-------|
244+
| `NOT_AUTHENTICATED` | No valid session |
245+
| `PRINCIPAL_CANNOT_CREATE_PRINCIPAL` | A principal session tried to create another principal (AuthzHumanOnly) |
246+
| `PRINCIPAL_CANNOT_DELETE_PRINCIPAL` | A principal session tried to delete a principal (AuthzHumanOnly) |
247+
| `PRINCIPAL_NOT_FOUND` | The principal ID doesn't exist |
248+
| `NOT_OWNER` | Caller doesn't own the principal |
249+
250+
## Workflow: Create a Principal with an API Key
251+
252+
A typical workflow to create a fully usable principal:
253+
254+
```typescript
255+
// 1. Create the principal (human session required)
256+
const principal = (await db.createPrincipal({
257+
data: { name: 'deploy-bot', allowedMask: null, isReadOnly: false, bypassStepUp: true, entityIds: null },
258+
select: { id: true, userId: true }
259+
}).execute()).unwrap();
260+
261+
// 2. Create an API key for the principal
262+
const apiKey = (await db.createApiKey({
263+
data: {
264+
principalId: principal.id,
265+
name: 'deploy-bot-key'
266+
},
267+
select: { id: true, secret: true }
268+
}).execute()).unwrap();
269+
270+
// 3. The API key secret can now be used to authenticate as this principal
271+
// When authenticated, jwt.claims.principal_id = principal.userId
272+
// and jwt.claims.user_id = the human who created it
273+
```
274+
275+
## References
276+
277+
- `constructive-db-principals` skill — SQL-level internals (module generator, SPRT integration, RLS policies)
278+
- `constructive-db-security` skill — AuthzHumanOnly policy details
279+
- `references/principal-fields.md` — Field reference for principals and principal_entities tables
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
name: constructive-principals-fields
3+
description: Field reference for principals and principal_entities tables — columns, types, defaults, and constraints.
4+
---
5+
6+
# Principal Field Reference
7+
8+
## `principals` table
9+
10+
| Field | GraphQL Name | Type | Default | Description |
11+
|-------|-------------|------|---------|-------------|
12+
| `id` | `id` | UUID | auto | Primary key |
13+
| `owner_id` | `ownerId` | UUID (FK → users) || Parent human who owns this principal |
14+
| `user_id` | `userId` | UUID (FK → users) || Principal's identity row (type=3) |
15+
| `name` | `name` | String || Display name (e.g., `'billing-bot'`) |
16+
| `allowed_mask` | `allowedMask` | BitVarying | NULL | Permission subset bitmask. NULL = all parent permissions |
17+
| `is_read_only` | `isReadOnly` | Boolean | false | Read-only mode flag |
18+
| `bypass_step_up` | `bypassStepUp` | Boolean | true | Skip MFA step-up (principals can't do MFA) |
19+
| `created_at` | `createdAt` | DateTime | now() | Creation timestamp |
20+
| `updated_at` | `updatedAt` | DateTime | now() | Last update timestamp |
21+
22+
**RLS:** `AuthzDirectOwner` on `owner_id` — users can only SELECT their own principals.
23+
24+
**Mutations:** `create_principal` and `delete_principal` (both AuthzHumanOnly, SECURITY DEFINER).
25+
26+
## `principal_entities` table
27+
28+
| Field | GraphQL Name | Type | Default | Description |
29+
|-------|-------------|------|---------|-------------|
30+
| `id` | `id` | UUID | auto | Primary key |
31+
| `principal_id` | `principalId` | UUID (FK → principals, CASCADE) || The principal being scoped |
32+
| `entity_id` | `entityId` | UUID (FK → users) || The org this principal can access |
33+
34+
**Unique constraint:** `(principal_id, entity_id)` — no duplicate scoping.
35+
36+
**RLS:** `AuthzDirectOwner` via the principal's `owner_id` (joined through `principal_id → principals.owner_id`).
37+
38+
**Scoping semantics:**
39+
- No rows = unrestricted (principal inherits access to all parent's orgs)
40+
- 1+ rows = restricted to only those specific orgs
41+
42+
## `create_principal` mutation
43+
44+
| Parameter | GraphQL Name | Type | Required | Description |
45+
|-----------|-------------|------|----------|-------------|
46+
| `name` | `name` | String | Yes | Display name |
47+
| `allowed_mask` | `allowedMask` | BitVarying | No | Permission bitmask (NULL = all) |
48+
| `entity_ids` | `entityIds` | [UUID] | No | Org IDs to scope to (NULL = unrestricted) |
49+
| `is_read_only` | `isReadOnly` | Boolean | No | Default: false |
50+
| `bypass_step_up` | `bypassStepUp` | Boolean | No | Default: true |
51+
52+
**Returns:** The created principal record.
53+
54+
**Guards:** AuthzHumanOnly — only callable by human sessions.
55+
56+
**Side effects:**
57+
1. Creates a `users` row with `type = 3`
58+
2. Creates a `principals` row
59+
3. If `entity_ids` provided, creates `principal_entities` rows
60+
61+
## `delete_principal` mutation
62+
63+
| Parameter | GraphQL Name | Type | Required | Description |
64+
|-----------|-------------|------|----------|-------------|
65+
| `principal_id` | `principalId` | UUID | Yes | The principal to delete |
66+
67+
**Returns:** Success indicator.
68+
69+
**Guards:** AuthzHumanOnly + ownership check (must own the principal).
70+
71+
**Side effects:** CASCADE deletes the principal's `users` row, `principal_entities` rows, SPRT entries, and any associated credentials/sessions.

0 commit comments

Comments
 (0)