Skip to content

Commit dd83280

Browse files
committed
chore(1.0): prep release — README/doc red-team, admin audit attribution, CI guards
Folds all pending work into the unreleased 1.0.0 cut so every published package ships at 1.0.0: the three pending changesets are folded into each package's curated [1.0.0] CHANGELOG and removed, and .changeset/config.json gains onlyUpdatePeerDependentsWhenOutOfRange so a future core minor no longer force-majors the satellites through their peer ranges. README & package-docs red team - Corrected the sso/billing/backup/websockets README stability badges from experimental to release candidate (they contradicted docs/reference/stability.md); split the core/root blockquote so the satellite *packages* read release candidate and only the in-core opt-in features stay experimental. - Configure-first install for billing/backup/sso; per-package corrections: sso migration ownership (the package owns tenant_sso_configs; migration:run --connection=backoffice), backup required @adonisjs/queue peer + backup_encryption doctor check + commands table, billing Configuration section, websockets onTenantEvent handler rule. - New packages/reporting/README.md (the only satellite without one). - core/root highlight counts corrected to match the code (28 typed events, 39 admin endpoints, nine doctor checks), test-count badge softened to non-numeric, and the reference-app/contributing snippets point at the real examples/api/docker-compose.yml. - Fixed adjacent doc bugs surfaced in review: a nonexistent compose.test.yml reference, broken core examples/api/ relative links, and a backup docs flag table documenting a nonexistent tenant:clone --dest. CI guards (so the above can't silently drift again) - check-stability-versions.mjs now also asserts each README badge mirrors the matrix. - New scripts/check-readme-links.mjs (relative + docs-site links resolve) and scripts/check-readme-counts.mjs (doctor=9 / admin endpoints=39 / events=28 pinned to source), wired into the lint-and-typecheck CI job. Admin audit attribution - Every mutating admin action (REST, the matching ace commands, and custom actions) writes an attributed, append-only admin:<resource>:<verb> audit row sourced from resolveAdminActor — never the request body, never a secret; best-effort, never fails the operation it records. Lifecycle commands gain an optional --admin=<id> flag (absent records the row as system). - Deleting a non-existent webhook/feature flag now returns 404; an empty webhook PUT and disabling an already-disabled SSO config short-circuit with unchanged:true and write no row; admin OpenAPI info.version mirrors package.json; the admin sso peer is marked optional in the manifest. Also folds the metrics-bundle fix (configure --with=metrics now publishes all three metrics tables) into the core [1.0.0] changelog. Gates: stability/links/counts/doc-paths/positioning/graduation/lockfile, lint, and test:integrity all green. docs:build was validated earlier in this work and is not re-run here (local memory ceiling); its dead-link gate is unaffected by these link-neutral doc edits.
1 parent ae17bf9 commit dd83280

61 files changed

Lines changed: 2129 additions & 173 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/config.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"access": "public",
88
"baseBranch": "master",
99
"updateInternalDependencies": "patch",
10+
"onlyUpdatePeerDependentsWhenOutOfRange": true,
1011
"ignore": [
1112
"@adonisjs-lasagna/saas-tenancy-example-api",
1213
"@adonisjs-lasagna/saas-tenancy-benchmarks",

.changeset/metrics-bundle-custom-and-monthly-tables.md

Lines changed: 0 additions & 14 deletions
This file was deleted.

.github/workflows/ci.yml

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,25 @@ jobs:
8888
- name: Lint
8989
run: npm run lint
9090

91-
# Versions must agree with the stability labels in docs/reference/stability.md
92-
# (experimental -> 0.x, release candidate/stable -> >=1.0.0). Labels are
93-
# parsed from the doc, so relabeling without re-versioning fails here.
91+
# Versions AND each package README's stability badge must agree with the
92+
# stability labels in docs/reference/stability.md (experimental -> 0.x,
93+
# release candidate/stable -> >=1.0.0; the README badge mirrors the matrix
94+
# label). Labels are parsed from the doc, so relabeling without re-versioning
95+
# — or without updating the README badge — fails here.
9496
- name: Stability labels vs. versions
9597
run: node scripts/check-stability-versions.mjs
9698

99+
# Every README link must resolve: relative links from the README's own
100+
# directory (the way GitHub and npm render them) and docs-site links to a
101+
# real docs page. The dead-link gate only covers links INSIDE docs/.
102+
- name: README link guard
103+
run: node scripts/check-readme-links.mjs
104+
105+
# The core + root README structural counts (doctor checks, admin endpoints,
106+
# typed events) are pinned to their source of truth so they can't drift.
107+
- name: README count guard
108+
run: node scripts/check-readme-counts.mjs
109+
97110
# Every satellite labeled `release candidate` must clear the graduation gate
98111
# (own coverage gate, ABI-versioned manifest, configure hook, CHANGELOG, doc
99112
# page, version >= 1.0.0), so a satellite can't carry the RC label without

README.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# @adonisjs-lasagna/saas-tenancy
22

33
<p align="center">
4-
<img src="assets/hero.webp" alt="Lasagna — SaaS multi-tenancy for AdonisJS 7. Every tenant lives in its own isolated PostgreSQL schema. Around it: connection routing, circuit breaking, queues, contextual logging, plans and quotas, scheduled backups with retention, read-replica routing, soft delete, and a satellite suite covering audit logs, webhooks, branding, SSO, feature flags, metrics, and Stripe billing." width="100%" />
4+
<img src="assets/hero.webp" alt="Lasagna — SaaS multi-tenancy for AdonisJS 7. Every tenant lives in its own isolated PostgreSQL schema. Around it: connection routing, circuit breaking, queues, contextual logging, plans and quotas, scheduled backups with retention, read-replica routing, soft delete, and a satellite suite covering audit logs, webhooks, branding, SSO, feature flags, metrics, and multi-provider billing (Stripe, Paddle, Lemon Squeezy)." width="100%" />
55
</p>
66

77
[![npm](https://img.shields.io/npm/v/@adonisjs-lasagna/saas-tenancy?color=C26A4B&label=npm)](https://www.npmjs.com/package/@adonisjs-lasagna/saas-tenancy)
@@ -16,7 +16,7 @@
1616

1717
📖 **[Full documentation →](https://arcoders.github.io/Adonisjs-lasagna-saas-tenancy/)** · [Quickstart](https://arcoders.github.io/Adonisjs-lasagna-saas-tenancy/start/quickstart) · [Why Lasagna](https://arcoders.github.io/Adonisjs-lasagna-saas-tenancy/start/why) · [Comparison vs stancl](https://arcoders.github.io/Adonisjs-lasagna-saas-tenancy/reference/comparison) · [Release notes](https://arcoders.github.io/Adonisjs-lasagna-saas-tenancy/reference/release-notes)
1818

19-
> **Stability: release candidate.** The isolation core is feature complete and green in CI against real Postgres and Redis, but the `stable` label is withheld until an independent security review and production mileage close. The satellites (billing, SSO, admin, backup, and the opt-in in-core features like quotas, webhooks, and metrics) are **experimental**. Full breakdown and the 1.x semver promise in the [stability matrix](https://arcoders.github.io/Adonisjs-lasagna-saas-tenancy/reference/stability).
19+
> **Stability: release candidate.** The isolation core is feature complete and green in CI against real Postgres and Redis, but the `stable` label is withheld until an independent security review and production mileage close. The satellite **packages** (admin, SSO, billing, backup, websockets, reporting) are **release candidate** too; the opt-in in-core features (quotas, webhooks, metrics, audit logs, branding, feature flags, impersonation) are **experimental**. Full breakdown and the 1.x semver promise in the [stability matrix](https://arcoders.github.io/Adonisjs-lasagna-saas-tenancy/reference/stability).
2020
2121
I built this because the AdonisJS ecosystem deserved a proper multi
2222
tenancy foundation, and because every SaaS I touched eventually outgrew
@@ -38,16 +38,16 @@ and runs the full e2e suite against it.
3838
| **Schema isolation** | Each tenant gets its own `tenant_<uuid>` PostgreSQL schema, provisioned and routed automatically. |
3939
| **Circuit breaker** | Opossum wraps every tenant DB call; OPEN state is restored from Redis on restart so a known-down tenant DB fails fast across deploys. One bad schema can't take down the others. |
4040
| **Dependency resilience** | Per-dependency fail-open/fail-closed degradation policy via `ResilienceService`. Emits `DependencyDegraded` for alerting and returns a typed 503 (`DependencyUnavailableException`) when fail-closed. |
41-
| **Lifecycle hooks + 25 typed events** | Declarative `before` / `after` hooks wired into commands and jobs. 14 tenant/quota-lifecycle + 10 billing + 1 resilience event. |
41+
| **Lifecycle hooks + 28 typed events** | Declarative `before` / `after` hooks wired into commands and jobs. 18 core (tenant / quota / maintenance / resilience / metrics lifecycle) + 10 billing. |
4242
| **Contextual logging** | `tenantId` rides along through HTTP and queue jobs via `AsyncLocalStorage`. |
43-
| **`tenant:doctor`** | Ten built-in checks, `--fix` for auto-recovery, `--json` for CI, `--watch` for a live TUI. |
43+
| **`tenant:doctor`** | Nine built-in checks (plus `backup_recency` and `backup_encryption` when the backup satellite is installed), `--fix` for auto-recovery, `--json` for CI, `--watch` for a live TUI. |
4444
| **Plans and quotas** | Declarative plans, rolling counters, snapshot usage, an `enforceQuota()` middleware that returns 429 and emits `TenantQuotaExceeded`. |
4545
| **Scheduled backups + retention** | Tier-based intervals and `keepLast`, S3 mirror with purge awareness, idempotent cron command. |
4646
| **Health probes + Prometheus** | `/livez`, `/readyz`, `/healthz`, `/metrics`. No `prom-client` peer dep. |
4747
| **Read replica routing** | Round-robin, random, or sticky-by-tenant-id with stable connection naming. |
48-
| **REST admin API** | 36 endpoints + OpenAPI 3.1 spec + Swagger UI. You bring the auth middleware. |
48+
| **REST admin API** | 39 endpoints + OpenAPI 3.1 spec + Swagger UI. You bring the auth middleware. |
4949
| **Soft delete TTL** | Recycle bin pattern. `--keep-schema` on destroy, `tenant:purge-expired` on a cron. |
50-
| **Ten satellites** | Audit logs (append-only at the SQL level), webhooks (HMAC-signed + retries + verifier helper), quotas, feature flags, branding, SSO/OIDC, real-time WebSockets (socket.io, tenant-isolated), metrics, impersonation, and Stripe billing (idempotent webhook + dunning + metered + checkout/portal + lifecycle). All optional. |
50+
| **Ten satellites** | Audit logs (append-only at the SQL level), webhooks (HMAC-signed + retries + verifier helper), quotas, feature flags, branding, SSO/OIDC, real-time WebSockets (socket.io, tenant-isolated), metrics, impersonation, and multi-provider billing (Stripe / Paddle / Lemon Squeezy: idempotent webhook + dunning + metered + checkout/portal + lifecycle). All optional. |
5151

5252
Two questions to ask before adopting:
5353

@@ -100,8 +100,8 @@ every feature end-to-end:
100100

101101
```bash
102102
cd examples/api
103-
npm install
104-
docker compose -f compose.test.yml up -d
103+
npm install --legacy-peer-deps
104+
docker compose up -d
105105
npm run test:e2e
106106
```
107107

@@ -126,7 +126,7 @@ state machine.
126126
npm install --legacy-peer-deps
127127
npm run typecheck
128128
npm test
129-
docker compose -f compose.test.yml up -d
129+
docker compose -f examples/api/docker-compose.yml up -d
130130
npm run test:integration
131131
npm run docs:dev # live preview of the docs site
132132
```

docs/guides/satellites/admin.md

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,123 @@ multitenancyAdminRoutes({
5353
- **Optional SSO peer.** The SSO endpoints require `@adonisjs-lasagna/sso`; when it
5454
is not installed they return 501 and the rest of the admin API keeps working.
5555

56+
## Audit & accountability
57+
58+
Every mutating admin action is recorded automatically in the append-only audit
59+
log the moment it succeeds. The acting admin comes from your `resolveAdminActor`
60+
hook (never the request body), the row lands in `backoffice.tenant_audit_logs`,
61+
and a secret is never written to the metadata. Auditing is best-effort by design:
62+
a missing resolver or an unreachable audit table degrades to a swallowed no-op
63+
rather than failing the operation it records.
64+
65+
Actions follow one convention, `admin:<resource>:<verb>`, shared across REST and
66+
the equivalent ace commands. The caller is distinguished by `actorType`
67+
(`admin` when an operator id resolves, `system` for an unattributed privileged
68+
context), not by the action name.
69+
70+
| Action | Fires on | Metadata (ids and flags only) |
71+
|---|---|---|
72+
| `admin:tenant:create` | tenant created (`POST /tenants`, `tenant:create`) | `name`, `status` |
73+
| `admin:tenant:activate` | tenant activated | `status` |
74+
| `admin:tenant:suspend` | tenant suspended | `status` |
75+
| `admin:tenant:destroy` | tenant soft-deleted / torn down | `status`, `schemaDropped` |
76+
| `admin:tenant:restore` | soft-deleted tenant restored | `status` |
77+
| `admin:tenant:maintenance_enter` / `_exit` | maintenance toggled | `hasMessage` (on enter) |
78+
| `admin:webhook:create` / `update` / `delete` / `retry` | webhook changes and re-sends | `webhookId` (+ `url`, `events`, `secretGenerated`, `changed`, `deliveryId`) |
79+
| `admin:feature_flag:create` / `update` / `delete` | flag changes | `flag`, `enabled`, `hasExpiry` |
80+
| `admin:branding:update` | branding upsert | `changed` (the keys supplied) |
81+
| `admin:sso:update` / `disable` | SSO config changes | `provider`, `clientId`, `issuerUrl`, `enabled` (never the `clientSecret`) |
82+
| `admin:quota:set_usage` / `reset` | quota usage written or cleared | `quota`, `value` |
83+
| `admin:action:dispatch` | a host-registered custom action runs | `name` (never the body or result) |
84+
| `admin:impersonate:start` / `first-use` / `stop` | impersonation lifecycle | session id, target user, duration, reason |
85+
86+
Idempotent no-ops do not write a row: an already-suspended tenant, a webhook
87+
`PUT` with an empty body, or disabling an already-disabled SSO config all
88+
short-circuit with `unchanged: true` and audit nothing. A delete of a missing
89+
webhook or feature flag returns 404 and records nothing, so the trail never
90+
contains a deletion that did not happen.
91+
92+
A serialized row looks like this:
93+
94+
```json
95+
{
96+
"actorType": "admin",
97+
"actorId": "user-42",
98+
"action": "admin:tenant:suspend",
99+
"tenantId": "1f1b…",
100+
"metadata": { "status": "suspended" },
101+
"ipAddress": "203.0.113.10",
102+
"createdAt": "2026-06-26T12:00:00.000Z"
103+
}
104+
```
105+
106+
Rows are immutable at the database level: three PostgreSQL triggers reject
107+
`UPDATE`, `DELETE`, and `TRUNCATE`. Read them over HTTP at
108+
`GET {prefix}/tenants/:id/audit-logs?from=…&to=…` (paginated, date-range
109+
filterable) or export the full trail with `node ace tenant:audit:export`.
110+
111+
## Configuring the admin actor resolver
112+
113+
`resolveAdminActor` extracts the acting admin id from the authenticated context.
114+
It powers both impersonation and the audit attribution above.
115+
116+
```ts
117+
// start/routes.ts
118+
import { multitenancyAdminRoutes } from '@adonisjs-lasagna/admin'
119+
import { middleware } from '#start/kernel'
120+
121+
multitenancyAdminRoutes({
122+
middleware: middleware.adminAuth(),
123+
resolveAdminActor: ({ auth }) => auth.user?.id ?? null,
124+
})
125+
```
126+
127+
<Callout type="warning" title="The resolver records who, not what's allowed">
128+
<code>resolveAdminActor</code> must sit <strong>behind</strong> your
129+
authorization checks. It records <em>who</em> acted, not <em>which permission</em>
130+
allowed it. Per-action permission gating (RBAC) belongs in your middleware or
131+
policies, not in the package. Never read the admin id from the request body.
132+
</Callout>
133+
134+
The same operation run from the terminal is attributed too: the lifecycle ace
135+
commands (`tenant:create`, `tenant:activate`, `tenant:suspend`, `tenant:destroy`,
136+
`tenant:maintenance`) accept `--admin=<id>` to attribute the row to an operator.
137+
Without it the action is recorded as `system`.
138+
139+
## Retention & monitoring
140+
141+
Admin actions land in `backoffice.tenant_audit_logs`, and retention is your
142+
responsibility: the package only writes append-only rows, it never prunes them.
143+
The two supported patterns (ship to a long-term store then purge under a
144+
privileged role, or partition by month and detach old partitions) are covered in
145+
the [Audit logs guide](/guides/satellites/audit). For monitoring, register an
146+
`AuditLogDestinationRegistry` destination to fan rows out to a SIEM, and alert on
147+
high-signal actions. For example, surface every `admin:tenant:destroy` without a
148+
matching change ticket:
149+
150+
```bash
151+
node ace tenant:audit:export --format=json \
152+
| jq 'select(.action == "admin:tenant:destroy")'
153+
```
154+
155+
## Auditing denied attempts
156+
157+
A caller rejected by your auth middleware (401/403) never reaches the package, so
158+
the admin API cannot record a denial it never sees. That is the correct boundary.
159+
To audit denied attempts, write the row in your own auth middleware:
160+
161+
```ts
162+
import { AuditLogService } from '@adonisjs-lasagna/saas-tenancy/services'
163+
164+
const audit = await app.container.make(AuditLogService)
165+
await audit.log({
166+
actorType: 'admin',
167+
action: 'admin:access:denied',
168+
metadata: { reason: 'missing_scope' },
169+
ipAddress: ctx.request.ip(),
170+
})
171+
```
172+
56173
## Extensibility: custom actions
57174

58175
Register an `AdminAction` on the module-level `adminActionRegistry` to add a
@@ -67,6 +184,7 @@ Versioned via `ADMIN_CONTRACT_VERSION`; see the
67184
## Read next
68185

69186
- [Admin REST API](/guides/satellites/admin-rest-api); the full endpoint and OpenAPI reference.
187+
- [Audit logs](/guides/satellites/audit); the append-only trail, retention, and export.
70188
- [Security](/guides/security); auth, fail-closed mounting, and the actor resolver.
71189
- [Impersonation](/guides/satellites/impersonation); the impersonation model.
72190
- [Production checklist](/reference/production-checklist); the hardening runbook before you ship.

docs/guides/satellites/audit.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,17 @@ call.
1515

1616
- Impersonation sessions: `admin:impersonate:start`,
1717
`admin:impersonate:first-use`, and `admin:impersonate:stop`.
18-
19-
That is the only built-in writer today. Everything else (tenant
20-
lifecycle transitions, webhook changes, branding/SSO updates, quota
21-
breaches) is recorded by *your* code via `audit.log()`; the
22-
[lifecycle hooks](/reference/hooks) and [events](/reference/events) give you
18+
- Admin mutations, when you mount [`@adonisjs-lasagna/admin`](/guides/satellites/admin)
19+
or run the lifecycle ace commands (`tenant:create`, `tenant:suspend`,
20+
`tenant:destroy`, and the rest): every tenant lifecycle transition, webhook
21+
change, feature-flag/branding/SSO update, quota write, and custom-action
22+
dispatch records an attributed `admin:<resource>:<verb>` row. See the
23+
[Admin guide's Audit & accountability section](/guides/satellites/admin#audit-accountability)
24+
for the full action table.
25+
26+
Beyond those, anything specific to *your* application (domain events, quota
27+
breaches, your own background jobs) is recorded by your code via `audit.log()`;
28+
the [lifecycle hooks](/reference/hooks) and [events](/reference/events) give you
2329
clean attachment points:
2430

2531
```ts

docs/guides/satellites/backup.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ tenant:backup --tenant=<id> Dump one tenant's schema
5151
tenant:backups:run Back up every active tenant
5252
tenant:backup:list --tenant=<id> List a tenant's backups
5353
tenant:restore --tenant=<id> --file=<name> Restore a dump into a tenant
54-
tenant:clone --source=<id> --dest=<id> Clone one tenant's schema into another
54+
tenant:clone --source=<id> --name=<n> --email=<e> Clone a source tenant into a new one
5555
tenant:import --tenant=<id> --file=<f.sql> Import a .sql dump into a tenant schema
5656
```
5757

docs/guides/satellites/impersonation.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,13 @@ Expiry needs no audit row of its own; the session simply disappears
9595
when its Redis TTL lapses; the `start` row carries the planned
9696
duration.
9797

98+
Impersonation is one writer among several. When you mount the
99+
[admin satellite](/guides/satellites/admin), every other admin mutation
100+
(tenant lifecycle, webhooks, feature flags, branding, SSO, quotas, custom
101+
actions) is also recorded under an `admin:<resource>:<verb>` action with the
102+
acting admin attributed. See the
103+
[Admin guide's Audit & accountability section](/guides/satellites/admin#audit-accountability).
104+
98105
## Security guarantees
99106

100107
- Tokens are HMAC-SHA256 over a random 16-byte session id, so a

docs/guides/satellites/sso.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ document is cached for one hour and hardened before it is trusted:
6262
## Storing config
6363

6464
Per-tenant OIDC settings live in the `tenant_sso_configs` row (the
65-
migration stub ships with the core):
65+
migration stub ships with this package and is published by
66+
`node ace configure @adonisjs-lasagna/sso`):
6667

6768
```ts
6869
import { TenantSsoConfig } from '@adonisjs-lasagna/sso'

0 commit comments

Comments
 (0)