@astram/guide 0.2.12 → 0.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/CAPABILITIES.md +64 -0
  2. package/package.json +1 -1
package/CAPABILITIES.md CHANGED
@@ -384,6 +384,17 @@ imports: [
384
384
  action or `*`). Allow resolves `void`; deny, a missing row and another tenant's row all throw
385
385
  the same `AuthorizationDeniedError` (403); an undeclared action throws `RegistrationError`
386
386
  (500) before any query. It returns no row data, writes no audit row and is not a list filter.
387
+ - **Type-level check before the first write**: `records.authorizeType(typeKey, action)` (also on
388
+ `BoundRecords` and the `transaction(fn)` handle) needs no record id and writes nothing. Use it
389
+ when a request may or may not write a type, so the grant decides, not the data: e.g. call
390
+ `authorizeType('my_issue', 'create')` before writing a batch that has 0–500 issue rows. It runs
391
+ the same type-level decision a `create` runs (and the same refusals): allow resolves `void`,
392
+ deny throws `AuthorizationDeniedError` (403), an undeclared action throws `RegistrationError`.
393
+ No query, no audit row; a deny you catch inside `transaction(fn)` does not make it
394
+ rollback-only (an uncaught one rolls it back, as any throw does). It proves the
395
+ type grant only: a `readScope` create can still be refused after insert (outside your
396
+ subtree), and for `read`/`update`/`delete`/custom actions use `authorize(typeKey, id, action)`
397
+ for the record-level answer.
387
398
  - **A handler that writes needs two grants**: `authorize()` for the custom action, then the
388
399
  write through the normal pipe (`records.update(...)`), which needs `update` and is audited.
389
400
  There is no write under the custom action's key. With `activate` alone the update is 403. To
@@ -418,6 +429,59 @@ imports: [
418
429
  - `actionsOf(reg)` (`@astram/guard`) returns a registration's vocabulary. `actionsFor(typeKey)`
419
430
  is deprecated and returns only the CRUD verbs for every type.
420
431
 
432
+ ## Separation of duties (ADR-0048; core 1.12, engine 0.14, guard 0.21, identity 0.18)
433
+ - **Declare it on the type** when the people who started or asked about a record must not
434
+ approve it:
435
+ ```ts
436
+ @ObjectType({
437
+ typeKey: 'my_batch',
438
+ actions: ['approve'],
439
+ separationOfDuties: [{
440
+ actions: ['approve'],
441
+ actorFields: ['createdBy'],
442
+ related: [{ typeKey: 'my_review_request', via: 'batchUuid', actorFields: ['createdBy'] }],
443
+ }],
444
+ // ...
445
+ })
446
+ ```
447
+ `approve` is then denied (403) to whoever created the batch and to whoever created any
448
+ `my_review_request` row whose `batchUuid` is this batch, whatever their grants, owner
449
+ rights, shares or cross-tenant grants. No one is exempt: service accounts, delegated
450
+ operations and root (on a control-plane type) are bound too. Check it with
451
+ `records.authorize('my_batch', id, 'approve')`; an `update`/`delete` bound by the rule is
452
+ checked by the write itself. `records.authorizeType(typeKey, action)` refuses an action the
453
+ rule binds (`RegistrationError`): a type-level check cannot apply it. List both types in
454
+ `extraObjects`.
455
+ - What counts: the record's own `actorFields` (`trusted: 'actorId'` fields, or `uuid` fields
456
+ declared `immutable: true`, so nobody named can clear them first) and every related row's
457
+ `actorFields` (`trusted: 'actorId'` fields only, e.g. `createdBy`), every related row whatever
458
+ its status or tenant. Related rows are found by a `uuid` field that holds this record's id
459
+ (`via`, "down" only): declare the action on the record that others point at, and create a
460
+ related row only after reaching its `via` record (`records.get` / `authorize`).
461
+ - The rule reads the related rows that exist now: a requester who deletes their request is no
462
+ longer named. Do not grant `delete` on the related type to the people it names; cancel
463
+ requests through a status field instead (a cancelled row still counts).
464
+ - It needs the authority `IdentityFeatureModule` builds (not the legacy `decisionAuthority`
465
+ option) and a single installed `@astram/engine`; otherwise every bound action is denied.
466
+ - What it never does: bind `read` or `create` (boot refuses both), filter lists, or write an audit
467
+ row for the deny. If the principals cannot be read (a related value that is not a string, more
468
+ than 1,000 distinct people on one related field) the action is denied. A value of
469
+ `shield.separationOfDuties` that the engine did not build is dropped before the decision.
470
+ - Inside `records.transaction(fn)` the check holds a shared lock on the related rows it read, so
471
+ a new review request cannot be added between the check and your write; commit promptly. This
472
+ needs the MariaDB default isolation (REPEATABLE READ) and an index on the `via` column. An
473
+ approver and a requester racing can deadlock (MariaDB error 1213): retry the transaction.
474
+ - A bad declaration (undeclared action, unknown field, an own actor field that is neither `uuid`
475
+ nor `trusted: 'actorId'`, an own `uuid` actor field not declared `immutable: true`, a related
476
+ actor field that is not `trusted: 'actorId'`, a `via` that is
477
+ not an untrusted `uuid` field, unregistered related type, a tenant-scoped type pointing at a
478
+ type with no tenant column) fails boot with `RegistrationError`.
479
+ - **Tenant policies on people fields**: a `trusted: 'actorId'` field (e.g. `createdBy`) is a
480
+ reference to `user`, and `@Field({ references: 'user' })` marks any other field as one. Record
481
+ policies may then use `is_owner` / `is_not_owner` on it ("is me" / "is not me" in the
482
+ scaffolded builder), e.g. deny `activate` where `createdBy` is me. A tenant admin can delete
483
+ such a policy: use the declared rule above for anything the app requires.
484
+
421
485
  ## File upload and object storage (`@astram/guard` + `@astram/identity`)
422
486
  One file per request, kept in a private S3/MinIO bucket with SSE-S3, with a `stored_file` record
423
487
  on the record pipe. You write no storage, MIME or authorization code.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astram/guide",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
4
4
  "license": "MIT",
5
5
  "description": "Astram framework — the AI/dev GUIDE layer (ADR-0025). Ships the framework's hard rules + capabilities manifest as installed, upgrade-refreshed docs. A consumer app's CLAUDE.md references FRAMEWORK_RULES.md from this package; `npm update` replaces it wholesale so new rules arrive cleanly, while the app's own .astram/guide/ layer is never touched. This is the framework-owned FLOOR: an app rule may tighten it, never weaken it.",
6
6
  "type": "commonjs",