@astram/guide 0.2.9 → 0.2.11
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.
- package/CAPABILITIES.md +102 -11
- package/FRAMEWORK_RULES.md +28 -0
- package/package.json +6 -1
package/CAPABILITIES.md
CHANGED
|
@@ -100,8 +100,9 @@ imports: [
|
|
|
100
100
|
- **Lockout and rate limits**: per-account login and forgot-password budgets (keyed on the
|
|
101
101
|
resolved account, so spelling variants share one counter); per-IP limits for login,
|
|
102
102
|
forgot-password and reset-password, active only when Express `trust proxy` is set explicitly
|
|
103
|
-
(`TRUST_PROXY` in the scaffold).
|
|
104
|
-
path and the TOTP replay guard
|
|
103
|
+
(`TRUST_PROXY` in the scaffold). On a Redis error the login, MFA and recovery-code budgets fail
|
|
104
|
+
closed (503), as do the request path and the TOTP replay guard; the forgot-password and
|
|
105
|
+
reset-password per-IP counters fail open (logged).
|
|
105
106
|
- **Disabled accounts** cannot log in or refresh; disabling a user revokes their sessions and
|
|
106
107
|
drops their roles at once.
|
|
107
108
|
- **Silent recovery**: after a Redis loss, the next refresh rebuilds the session from MariaDB
|
|
@@ -130,6 +131,27 @@ imports: [
|
|
|
130
131
|
link once. User reads carry a top-level `signInStatus` (`pending` | `active` | `none`). Frontend:
|
|
131
132
|
`identity.inviteUser(token, uuid)` (`@astram/frontend-core`); the scaffolded Users page has the
|
|
132
133
|
Invite button, and the reset page shows "Set your password" for `purpose=invite` links.
|
|
134
|
+
- **Sign-in options** (ADR-0045; guard 0.20, identity 0.17, frontend-core 0.8). Each is OFF until
|
|
135
|
+
you turn it on; an upgrade without new options changes nothing on the wire except the logout fix
|
|
136
|
+
below. Write none of this yourself (Rule 6).
|
|
137
|
+
|
|
138
|
+
| Option | Default | What it does | Risk / note |
|
|
139
|
+
|--------|---------|--------------|-------------|
|
|
140
|
+
| `ShieldModule.forRoot({ signInWith: ['email', 'username'] })` | `['email']` | One field takes the email or the user's login name (`sonja`). `'email'` must stay in the list (boot fails otherwise). | Login names are global: an admin who hits "That sign-in name is already in use." learns the name exists somewhere (no tenant is named). |
|
|
141
|
+
| `captcha: 'env'` | off | Cloudflare Turnstile or Google reCAPTCHA (v3 score, v2 checkbox) on login, forgot-password and reset / set-password, from `CAPTCHA_*` env. Adaptive by default: a challenge after 3 failures for the account, the typed name or the IP. | Platform-wide only. Outside development/test `CAPTCHA_SECRET` must be a `secret-ref:` and `CAPTCHA_EXPECTED_HOSTNAMES` is required. `CAPTCHA_FAIL_MODE=open` lets sign-in through when the provider is down (audited, limits still apply); keep `closed` unless you accept that. The frontend build needs `VITE_CAPTCHA_PROVIDER` = the server's `CAPTCHA_PROVIDER` (CSP); a mismatch shows a "misconfigured" notice. |
|
|
142
|
+
| `logoutRequiresProof: true` | `false` this release; `true` from the next guard major | `POST /auth/logout` ends a session only with proof: its refresh token, or a bearer bound to it. | With `false`, an unproven logout (an old `{ sessionId }`-only frontend) still ends the session and is counted (`auth.logout.unproven`); anyone holding a leaked session id can end that session. The scaffolded app sets `true`. A custom frontend must send `{ sessionId, refreshToken }` + bearer first (`@astram/frontend-core` `logout(session)` does). |
|
|
143
|
+
| Mandatory 2FA (`/identity/security-policies`, identity wired) | no policies | Admins require 2FA for everyone in a company subtree, holders of a role, or holders of a permission key (`identity:*` allowed); grace 0–14 days, per user from when they become subject. Root can set a platform floor. | A required user without 2FA can only reach the enrolment pages (403 `mfa_enrollment_required`) until they enrol; during grace `/auth/me` carries `mfaEnrollmentDeadline`. Root accounts are bound only by floor policies. Takes effect on the user's next request. |
|
|
144
|
+
|
|
145
|
+
- **What counts as 2FA for a policy:** a confirmed **TOTP** authenticator only, in this release.
|
|
146
|
+
Recovery codes never count. Email codes, passkeys and SMS are not available as factors yet.
|
|
147
|
+
- Removing the last device while a policy applies answers 409 `mfa_required_by_policy`.
|
|
148
|
+
- `GET /auth/login-options` (public) returns `{ signInWith, captcha: { provider, siteKey, mode } | null }`
|
|
149
|
+
for the login page; a 401 with `captcha: 'required'` asks the page to show the widget.
|
|
150
|
+
- Sign-in events are audited by default when `IdentityFeatureModule` is loaded (`auth.login.*`,
|
|
151
|
+
`auth.logout`, `auth.mfa.*`, `auth.captcha.*`, `auth.handle.changed`; failed logins only for
|
|
152
|
+
real accounts, at most one row per lockout window). Your own `auditSink` still wins.
|
|
153
|
+
- Restricted sessions (recovery-code login or mandatory 2FA) get 403 on `POST /auth/scope-switch`,
|
|
154
|
+
and `GET /identity/auth-facts` answers `{ facts: [], restricted: 'reenroll' | 'mfa_enrollment' }`.
|
|
133
155
|
- **Service-account API keys** (`Authorization: Bearer ask_…`) authenticate agents and machine
|
|
134
156
|
clients through the same `decide()` path; scope, status and roles are read live; issuing a key
|
|
135
157
|
needs an explicit expiry (`expiresAtMs`) or `neverExpires: true`.
|
|
@@ -165,10 +187,43 @@ imports: [
|
|
|
165
187
|
service is built without `IdentityFeatureModule.forRoot`) when no credential was made.
|
|
166
188
|
Once your `EmailSender` works, `POST /auth/forgot-password` reaches the user. A user whose email
|
|
167
189
|
is set later stays locked until the next `bootstrap:org` run gives them a credential.
|
|
168
|
-
- **
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
190
|
+
- **Roles belong to one company** (G-013, ADR-0047). A role is assigned only in its own company
|
|
191
|
+
(postings and service accounts; 400 otherwise, root included), read and managed through the owner
|
|
192
|
+
wall (a parent company's admins manage their subsidiaries' roles; out of reach ⇒ 404), and its name
|
|
193
|
+
is unique within its company only (409 names the existing role). `GET|POST
|
|
194
|
+
/identity/companies/:companyUuid/roles`; `POST /identity/roles` creates the role in the caller's
|
|
195
|
+
active company (400 "Choose a company for the role." without one). Role routes need
|
|
196
|
+
`identity:company:read` (the `access-admin` bundle grants it). An ownerless role (seeded without
|
|
197
|
+
`r_owner_company_uuid`) is root-only and assignable nowhere.
|
|
198
|
+
- **Departments and branches belong to one company** (G-012). `POST
|
|
199
|
+
/identity/companies/:companyUuid/departments` and `/branches` (`{ name }`), `PUT` (rename) and
|
|
200
|
+
`DELETE` on `/identity/departments/:uuid` and `/identity/branches/:uuid`; the list routes are walled
|
|
201
|
+
and return `{ rows: [{ uuid, name, usedBy? }], total, page, limit }` (`?page&limit&search`). Names
|
|
202
|
+
are unique per company ignoring case and accents (409); delete is refused (409) while any posting
|
|
203
|
+
uses the row; at most 1000 of each per company. Access bundle `org-structure`.
|
|
204
|
+
- **The active membership is always `active`** (G-011): `PUT /identity/memberships/:uuid` refuses
|
|
205
|
+
activating a non-active membership or moving the active one off `active` (409), and a body that
|
|
206
|
+
sets `isActive: true` with another status (400).
|
|
207
|
+
- **Security policies (mandatory 2FA, ADR-0045).** `GET|POST /identity/security-policies`,
|
|
208
|
+
`GET|PUT|DELETE /identity/security-policies/:uuid` (permissions
|
|
209
|
+
`identity:security_policy:{read,create,update,delete}`; no access bundle carries them). Any holder
|
|
210
|
+
can create or tighten a policy for a company in their subtree; lowering or deleting a role or
|
|
211
|
+
permission policy needs the grant ceiling over what it covers, a company-wide policy needs all of
|
|
212
|
+
`identity:*` at that company or above; the platform floor is root-only. Every write is a
|
|
213
|
+
`permission_change` audit row and takes effect on the affected users' next request.
|
|
214
|
+
- **Login names (G-014).** `loginName` on `POST /identity/users` and `PUT /identity/users/:uuid`
|
|
215
|
+
(`null` clears) (and optional `loginName` per
|
|
216
|
+
user in `bootstrap:org` files): `[a-z0-9._-]`, 3–64 characters, stored lower-case, never `@`.
|
|
217
|
+
Needs dominance over the user; audited `auth.handle.changed`; existing sessions stay. Users cannot
|
|
218
|
+
set or change their own (no `/account/*` route does).
|
|
219
|
+
- **Admin sign-in controls.** `POST /identity/users/:uuid/{reset-mfa,unlock,end-sessions}`
|
|
220
|
+
(permissions `identity:user:{reset_mfa,unlock,end_sessions}`), body `{ code }` = the admin's OWN
|
|
221
|
+
current TOTP code; an admin without 2FA gets 409 `mfa_required`; needs dominance; not on yourself.
|
|
222
|
+
Reset-MFA deletes the user's devices and recovery codes and ends their sessions; unlock clears the
|
|
223
|
+
login, MFA, recovery and CAPTCHA counters; end-sessions revokes every session. All audited. User
|
|
224
|
+
reads carry `mfaStatus` (`enrolled` | `required` | `grace` | `not_required`), never device data.
|
|
225
|
+
Roles holding `identity:*` or `identity:user:*` gain these actions (and `security_policy`) on
|
|
226
|
+
upgrade; exact-key roles need a grant.
|
|
172
227
|
|
|
173
228
|
## Organisation bootstrap (`npm run bootstrap:org`, `@astram/identity` `runOrgBootstrap`, ADR-0042)
|
|
174
229
|
- **What it does.** Creates your organisation from one JSON file at deploy time: platform roots,
|
|
@@ -191,7 +246,8 @@ imports: [
|
|
|
191
246
|
`ASTRAM_BOOTSTRAP_OPERATOR`. Root passwords: `ASTRAM_BOOTSTRAP_ROOT_PASSWORD_<USERNAME>` (a
|
|
192
247
|
`secret-ref:` outside development/test, so a `SECRETS_PROVIDER` is needed) or `--interactive`.
|
|
193
248
|
Other flags: `--reissue-links`, `--link-ttl-hours <1..168>` (default 72), `--links-to-stdout`,
|
|
194
|
-
`--claim-unowned-roles
|
|
249
|
+
`--claim-unowned-roles` (a no-op notice since roles belong to one company). Runs hold a database
|
|
250
|
+
lock.
|
|
195
251
|
- **First sign-in.** Non-root users get a locked credential and a one-time set-password link,
|
|
196
252
|
printed once, to a terminal only (`--links-to-stdout` sends them to a pipe, where logs can keep
|
|
197
253
|
them). The operator delivers each link; the user redeems it at the reset page. Outside
|
|
@@ -199,9 +255,11 @@ imports: [
|
|
|
199
255
|
- **Roots.** The run prints each root's uuid and a `SHIELD_ROOT_IDS=…` line: set it and restart
|
|
200
256
|
(`rootIdsFromEnv`, above).
|
|
201
257
|
- **Re-runs and new tenants.** Additive and idempotent: a second `--apply` writes nothing. A file
|
|
202
|
-
covers only the tenants it declares, so onboard a new tenant with a new file.
|
|
203
|
-
|
|
204
|
-
|
|
258
|
+
covers only the tenants it declares, so onboard a new tenant with a new file. Roles, departments
|
|
259
|
+
and branches are created in the company that declares them, and the same name may exist in
|
|
260
|
+
another company. A posting must use a role declared on its own company (a role declared only on a
|
|
261
|
+
parent is a file error). After renaming a department or branch in the app, rename it in the file
|
|
262
|
+
too, or a re-run creates the old name again.
|
|
205
263
|
- **What it does NOT do.** No deletes, renames, moves or revokes (differences are reported only);
|
|
206
264
|
it never activates an inactive membership or updates a changed email. No upload from the UI.
|
|
207
265
|
Department and branch admin routes are separate. With `emailSender` configured, `--email-links`
|
|
@@ -279,6 +337,28 @@ imports: [
|
|
|
279
337
|
(a Rule-4 STOP). Field types this version: `string`, `number`, `boolean`, `uuid`, `timestamp`,
|
|
280
338
|
`json`.
|
|
281
339
|
|
|
340
|
+
## Shared reference data: global types (ADR-0046; engine 0.11, guard 0.20)
|
|
341
|
+
- **Declare** `@ObjectType({ ..., access: { plane, global: true } })` for rows every tenant reads the
|
|
342
|
+
same (code lists, catalogues, units). No tenant field, no `readScope`. Registration refuses
|
|
343
|
+
`global` with `tenantScoped`, `owned`, `system`, a `readScope`, a `trusted: 'tenantId'` field,
|
|
344
|
+
`crudActions: true` or any action other than `read`, naming the type.
|
|
345
|
+
- **Reads** (list, get, `aggregate()`, search) carry no tenant clause, but the type's `read` grant
|
|
346
|
+
and deny policies still apply: a caller without `read` sees nothing. Root follows the plane:
|
|
347
|
+
`plane: 'data'` keeps root denied, `plane: 'control'` lets root read.
|
|
348
|
+
- **Writes** through the engine (create, update, delete, `createMany`) are refused for everyone,
|
|
349
|
+
root included, before any SQL. Seed and change rows only in an app migration, idempotent and
|
|
350
|
+
select-before-insert on the business key.
|
|
351
|
+
- **Policies do not cascade.** A record policy on a global type applies only to callers whose
|
|
352
|
+
active tenant is the policy's tenant, plus platform policies; a parent's deny does not reach its
|
|
353
|
+
child companies' users. To hide a row across a group, author the policy in each tenant or as a
|
|
354
|
+
platform policy.
|
|
355
|
+
- **No sharing, no cross-tenant reach**: a global type is never `owned`, so never a share target.
|
|
356
|
+
- **Tenant-less data types are now refused writes.** A `plane: 'data'` type with no tenant field,
|
|
357
|
+
no `readScope` and not global refuses non-root create, update, delete and `createMany`
|
|
358
|
+
(`AuthorizationDeniedError('tenantless_data_write_denied')`). Boot logs the global types once and
|
|
359
|
+
warns once per such type: make it global, give it a tenant field, or add a `readScope`.
|
|
360
|
+
- Never fake shared data with a match-all `readScope`, and never put tenant data in a global type.
|
|
361
|
+
|
|
282
362
|
## Custom object actions (ADR-0038; engine 0.7, guard 0.14, identity 0.11)
|
|
283
363
|
- **Declare domain verbs on the type**: `@ObjectType({ ..., actions: ['activate', 'review'] })`.
|
|
284
364
|
The type's vocabulary is `read, create, update, delete` plus the declared actions, and each one
|
|
@@ -486,6 +566,14 @@ upload(@Req() req: Record<string, unknown>) {
|
|
|
486
566
|
- The scaffolded frontend (`create-astram-app --with-frontend` or `npx -p create-astram-app
|
|
487
567
|
astram-add-frontend`) ships login, MFA, recovery, forgot/reset, account and admin pages, with a
|
|
488
568
|
strict Content-Security-Policy.
|
|
569
|
+
- Sign-in options (frontend-core 0.8): `auth.loginOptions()`, `captchaToken` on `login` /
|
|
570
|
+
`requestReset` / `resetPassword` (a failure carries `captchaRequired`), `restrictionReasonOf(body)`,
|
|
571
|
+
`logout(session)` sends the proof, `createSecurityPolicyService(http)`, and
|
|
572
|
+
`identity.{resetUserMfa,unlockUser,endUserSessions}(token, uuid, code)`. The scaffolded frontend
|
|
573
|
+
has the login label from `signInWith`, the CAPTCHA widget (`VITE_CAPTCHA_PROVIDER`,
|
|
574
|
+
`create-astram-app --captcha <provider>`), routing to 2FA setup on `mfa_enrollment_required`, a
|
|
575
|
+
grace banner, the Security page (policies), and Users-page 2FA status, sign-in name and admin
|
|
576
|
+
actions.
|
|
489
577
|
|
|
490
578
|
## Available, but NOT wired to a data path (do not assume they protect data yet)
|
|
491
579
|
- **Envelope encryption / per-tenant DEKs** (`createEncryptionService`, `LocalKekCrypto`): the
|
|
@@ -503,7 +591,10 @@ upload(@Req() req: Record<string, unknown>) {
|
|
|
503
591
|
- Per-principal column masking on reads (row-level scoping is provided; column-level is not).
|
|
504
592
|
- External identity providers (OIDC, SAML, LDAP, Entra ID, Cognito, Okta, Google Workspace) and
|
|
505
593
|
WebAuthn ceremonies.
|
|
506
|
-
-
|
|
594
|
+
- Second factors other than TOTP (email code, passkey/WebAuthn, SMS), passwordless email-code
|
|
595
|
+
sign-in, a per-tenant allowed-factor list, a user's own session list, security notification
|
|
596
|
+
emails, and a configurable password policy (mandatory 2FA by tenant, role or permission IS
|
|
597
|
+
provided, above).
|
|
507
598
|
- SMS and WhatsApp delivery adapters, phone numbers on users, phone verification and SMS/WhatsApp
|
|
508
599
|
MFA (the ports exist in `@astram/core`; adapters come with a phone-number identity feature).
|
|
509
600
|
- A durable outbox or queue for guaranteed delivery (sends are best-effort with one retry; re-issue
|
package/FRAMEWORK_RULES.md
CHANGED
|
@@ -58,6 +58,16 @@ infrastructure, and they may not re-export it). The lint wall is a backstop, not
|
|
|
58
58
|
sandbox: code that slips past it is still a Rule 2 violation. If you find yourself
|
|
59
59
|
wanting a raw query, see Rule 4.
|
|
60
60
|
|
|
61
|
+
**Shared reference data** (code lists, catalogues, units: the same rows for every
|
|
62
|
+
tenant) is an object type declared `access: { plane, global: true }`, with no tenant
|
|
63
|
+
field and no `readScope`. It is read-only through the engine for everyone; create and
|
|
64
|
+
change its rows only in an app migration (idempotent, select-before-insert on the
|
|
65
|
+
business key). Never fake shared data with a match-all `readScope`, and never put
|
|
66
|
+
tenant data in a global type. A record policy on a global type applies only to callers
|
|
67
|
+
whose active tenant is the policy's tenant (plus platform policies): it does not cascade
|
|
68
|
+
down the company hierarchy, so a parent's deny does not hide the row from its child
|
|
69
|
+
companies' users. To hide a row across a group, author the policy in each tenant.
|
|
70
|
+
|
|
61
71
|
## 3. Classify every route (deny-by-default)
|
|
62
72
|
|
|
63
73
|
Every HTTP route is DENIED by the global enforcement guard unless you classify it:
|
|
@@ -117,6 +127,24 @@ contributes configuration through the seam (`rootIds` from config, an `emailSend
|
|
|
117
127
|
own object types as `extraObjects`) and its data; access policy is authored by
|
|
118
128
|
administrators as role grants and record policies, not in code. See `CAPABILITIES.md`.
|
|
119
129
|
|
|
130
|
+
Sign-in options are configuration too, never app code (`CAPABILITIES.md`, "Sign-in options"):
|
|
131
|
+
- **Username sign-in:** `signInWith: ['email', 'username']`. Do not look users up by name
|
|
132
|
+
yourself, and do not let users set their own login name; admins set it (`loginName` on the users
|
|
133
|
+
API) or `bootstrap:org` does.
|
|
134
|
+
- **CAPTCHA:** `captcha: 'env'` plus the `CAPTCHA_*` variables and the same provider in
|
|
135
|
+
`VITE_CAPTCHA_PROVIDER`. Do not add your own CAPTCHA check, widget script origin or bot filter on
|
|
136
|
+
`/auth/*`; do not set `CAPTCHA_FAIL_MODE=open` without a recorded decision.
|
|
137
|
+
- **Mandatory 2FA:** admins create policies through `/identity/security-policies` (the Security
|
|
138
|
+
page). Do not gate routes on "has 2FA" in app code or keep your own list of who must enrol. In
|
|
139
|
+
this release only a confirmed **TOTP** authenticator counts as 2FA for a policy; recovery codes
|
|
140
|
+
never do. Handle 403 `mfa_enrollment_required` like `reenrollment_required`: send the user to the
|
|
141
|
+
2FA setup page.
|
|
142
|
+
- **Logout:** call `logout(session)` from `@astram/frontend-core` (it sends the refresh token and
|
|
143
|
+
bearer as proof) and set `logoutRequiresProof: true` once your frontend does. Never end a
|
|
144
|
+
session by id alone.
|
|
145
|
+
- **Unlock, reset 2FA, end sessions:** use the users API actions (`reset-mfa`, `unlock`,
|
|
146
|
+
`end-sessions`); never clear limiter keys or delete MFA rows yourself.
|
|
147
|
+
|
|
120
148
|
Never reimplement session handling, token verification, password hashing, or an
|
|
121
149
|
allow/deny check in app code. Never take a tenant, company, role or user id for an access
|
|
122
150
|
decision from the request body — the framework takes them from the session. If the auth
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astram/guide",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
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",
|
|
@@ -19,5 +19,10 @@
|
|
|
19
19
|
"sideEffects": false,
|
|
20
20
|
"publishConfig": {
|
|
21
21
|
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/ASTRAM-2-0/astram-framework.git",
|
|
26
|
+
"directory": "packages/guide"
|
|
22
27
|
}
|
|
23
28
|
}
|