@bajustone/fortress 1.0.0-rc.1

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 (465) hide show
  1. package/CHANGELOG.md +1011 -0
  2. package/LICENSE +21 -0
  3. package/README.md +760 -0
  4. package/SECURITY.md +197 -0
  5. package/bin/fortress.ts +667 -0
  6. package/dist/auth-service-BcyQ2PuZ.d.cts +307 -0
  7. package/dist/auth-service-BkzZkWfH.d.ts +307 -0
  8. package/dist/config-B2366s_G.d.ts +665 -0
  9. package/dist/config-GtlVt6ZD.d.cts +665 -0
  10. package/dist/convert-routes-BLCQT57f.d.ts +72 -0
  11. package/dist/convert-routes-BdMvP2_X.d.cts +72 -0
  12. package/dist/crypto.cjs +61 -0
  13. package/dist/crypto.d.cts +36 -0
  14. package/dist/crypto.d.ts +36 -0
  15. package/dist/crypto.js +35 -0
  16. package/dist/drift-BT1wtMDJ.d.cts +22 -0
  17. package/dist/drift-k9LiYpRP.d.ts +22 -0
  18. package/dist/drizzle/pg.cjs +481 -0
  19. package/dist/drizzle/pg.d.cts +15 -0
  20. package/dist/drizzle/pg.d.ts +15 -0
  21. package/dist/drizzle/pg.js +454 -0
  22. package/dist/drizzle.cjs +1442 -0
  23. package/dist/drizzle.d.cts +85 -0
  24. package/dist/drizzle.d.ts +85 -0
  25. package/dist/drizzle.js +1411 -0
  26. package/dist/express.cjs +1357 -0
  27. package/dist/express.d.cts +333 -0
  28. package/dist/express.d.ts +333 -0
  29. package/dist/express.js +1314 -0
  30. package/dist/fetcher.cjs +77 -0
  31. package/dist/fetcher.d.cts +7 -0
  32. package/dist/fetcher.d.ts +7 -0
  33. package/dist/fetcher.js +41 -0
  34. package/dist/fortress-BmSvFfqK.d.cts +1089 -0
  35. package/dist/fortress-uA-_cheR.d.ts +1089 -0
  36. package/dist/hono.cjs +1530 -0
  37. package/dist/hono.d.cts +514 -0
  38. package/dist/hono.d.ts +514 -0
  39. package/dist/hono.js +1483 -0
  40. package/dist/index-CxEkfCA0.d.cts +77 -0
  41. package/dist/index-CxEkfCA0.d.ts +77 -0
  42. package/dist/index-D054pcb-.d.cts +146 -0
  43. package/dist/index-jAMbbUAY.d.ts +146 -0
  44. package/dist/index.cjs +9046 -0
  45. package/dist/index.d.cts +717 -0
  46. package/dist/index.d.ts +717 -0
  47. package/dist/index.js +8935 -0
  48. package/dist/jwt.cjs +255 -0
  49. package/dist/jwt.d.cts +90 -0
  50. package/dist/jwt.d.ts +90 -0
  51. package/dist/jwt.js +227 -0
  52. package/dist/otel.cjs +108 -0
  53. package/dist/otel.d.cts +62 -0
  54. package/dist/otel.d.ts +62 -0
  55. package/dist/otel.js +73 -0
  56. package/dist/plugins/account-lockout.cjs +322 -0
  57. package/dist/plugins/account-lockout.d.cts +42 -0
  58. package/dist/plugins/account-lockout.d.ts +42 -0
  59. package/dist/plugins/account-lockout.js +295 -0
  60. package/dist/plugins/admin.cjs +2378 -0
  61. package/dist/plugins/admin.d.cts +72 -0
  62. package/dist/plugins/admin.d.ts +72 -0
  63. package/dist/plugins/admin.js +2351 -0
  64. package/dist/plugins/api-key.cjs +792 -0
  65. package/dist/plugins/api-key.d.cts +121 -0
  66. package/dist/plugins/api-key.d.ts +121 -0
  67. package/dist/plugins/api-key.js +765 -0
  68. package/dist/plugins/audit-log.cjs +493 -0
  69. package/dist/plugins/audit-log.d.cts +86 -0
  70. package/dist/plugins/audit-log.d.ts +86 -0
  71. package/dist/plugins/audit-log.js +468 -0
  72. package/dist/plugins/data-isolation.cjs +211 -0
  73. package/dist/plugins/data-isolation.d.cts +49 -0
  74. package/dist/plugins/data-isolation.d.ts +49 -0
  75. package/dist/plugins/data-isolation.js +186 -0
  76. package/dist/plugins/email-verification.cjs +273 -0
  77. package/dist/plugins/email-verification.d.cts +44 -0
  78. package/dist/plugins/email-verification.d.ts +44 -0
  79. package/dist/plugins/email-verification.js +246 -0
  80. package/dist/plugins/magic-link.cjs +231 -0
  81. package/dist/plugins/magic-link.d.cts +39 -0
  82. package/dist/plugins/magic-link.d.ts +39 -0
  83. package/dist/plugins/magic-link.js +204 -0
  84. package/dist/plugins/oauth.cjs +1696 -0
  85. package/dist/plugins/oauth.d.cts +406 -0
  86. package/dist/plugins/oauth.d.ts +406 -0
  87. package/dist/plugins/oauth.js +1669 -0
  88. package/dist/plugins/openapi.cjs +1185 -0
  89. package/dist/plugins/openapi.d.cts +6 -0
  90. package/dist/plugins/openapi.d.ts +6 -0
  91. package/dist/plugins/openapi.js +1158 -0
  92. package/dist/plugins/rate-limit/express.cjs +55 -0
  93. package/dist/plugins/rate-limit/express.d.cts +64 -0
  94. package/dist/plugins/rate-limit/express.d.ts +64 -0
  95. package/dist/plugins/rate-limit/express.js +30 -0
  96. package/dist/plugins/rate-limit/hono.cjs +51 -0
  97. package/dist/plugins/rate-limit/hono.d.cts +59 -0
  98. package/dist/plugins/rate-limit/hono.d.ts +59 -0
  99. package/dist/plugins/rate-limit/hono.js +26 -0
  100. package/dist/plugins/rate-limit/sveltekit.cjs +49 -0
  101. package/dist/plugins/rate-limit/sveltekit.d.cts +59 -0
  102. package/dist/plugins/rate-limit/sveltekit.d.ts +59 -0
  103. package/dist/plugins/rate-limit/sveltekit.js +24 -0
  104. package/dist/plugins/rate-limit.cjs +354 -0
  105. package/dist/plugins/rate-limit.d.cts +140 -0
  106. package/dist/plugins/rate-limit.d.ts +140 -0
  107. package/dist/plugins/rate-limit.js +325 -0
  108. package/dist/plugins/social-login.cjs +905 -0
  109. package/dist/plugins/social-login.d.cts +114 -0
  110. package/dist/plugins/social-login.d.ts +114 -0
  111. package/dist/plugins/social-login.js +880 -0
  112. package/dist/plugins/tenancy.cjs +780 -0
  113. package/dist/plugins/tenancy.d.cts +108 -0
  114. package/dist/plugins/tenancy.d.ts +108 -0
  115. package/dist/plugins/tenancy.js +753 -0
  116. package/dist/plugins/two-factor.cjs +555 -0
  117. package/dist/plugins/two-factor.d.cts +67 -0
  118. package/dist/plugins/two-factor.d.ts +67 -0
  119. package/dist/plugins/two-factor.js +526 -0
  120. package/dist/plugins/webauthn.cjs +786 -0
  121. package/dist/plugins/webauthn.d.cts +72 -0
  122. package/dist/plugins/webauthn.d.ts +72 -0
  123. package/dist/plugins/webauthn.js +766 -0
  124. package/dist/plugins/webhook.cjs +819 -0
  125. package/dist/plugins/webhook.d.cts +254 -0
  126. package/dist/plugins/webhook.d.ts +254 -0
  127. package/dist/plugins/webhook.js +789 -0
  128. package/dist/protect-D1v_0upK.d.cts +123 -0
  129. package/dist/protect-DsxV_-dJ.d.ts +123 -0
  130. package/dist/sveltekit.cjs +1258 -0
  131. package/dist/sveltekit.d.cts +420 -0
  132. package/dist/sveltekit.d.ts +420 -0
  133. package/dist/sveltekit.js +1207 -0
  134. package/dist/testing.cjs +4294 -0
  135. package/dist/testing.d.cts +197 -0
  136. package/dist/testing.d.ts +197 -0
  137. package/dist/testing.js +4264 -0
  138. package/dist/types-et0R8tsC.d.cts +217 -0
  139. package/dist/types-et0R8tsC.d.ts +217 -0
  140. package/docs/adapter-typed-helpers.md +192 -0
  141. package/docs/admin-recipes.md +227 -0
  142. package/docs/architecture.md +434 -0
  143. package/docs/ci/github-actions.yml +59 -0
  144. package/docs/ci.md +109 -0
  145. package/docs/compatibility.md +115 -0
  146. package/docs/deployment.md +305 -0
  147. package/docs/hardening.md +118 -0
  148. package/docs/host-owned-routes.md +154 -0
  149. package/docs/migrations/0001-schema-version.md +54 -0
  150. package/docs/migrations/0002-initial-schema.md +84 -0
  151. package/docs/migrations/upgrade-guide.md +160 -0
  152. package/docs/observability.md +394 -0
  153. package/docs/plugins/account-lockout.md +131 -0
  154. package/docs/plugins/admin.md +402 -0
  155. package/docs/plugins/api-key.md +327 -0
  156. package/docs/plugins/audit-log.md +261 -0
  157. package/docs/plugins/data-isolation.md +186 -0
  158. package/docs/plugins/email-verification.md +121 -0
  159. package/docs/plugins/magic-link.md +123 -0
  160. package/docs/plugins/oauth.md +544 -0
  161. package/docs/plugins/openapi.md +250 -0
  162. package/docs/plugins/rate-limit.md +223 -0
  163. package/docs/plugins/social-login.md +337 -0
  164. package/docs/plugins/tenancy.md +122 -0
  165. package/docs/plugins/two-factor.md +191 -0
  166. package/docs/plugins/webauthn.md +188 -0
  167. package/docs/plugins/webhook.md +242 -0
  168. package/docs/policy-as-code.md +156 -0
  169. package/docs/route-manifest.md +46 -0
  170. package/docs/security.md +261 -0
  171. package/docs/threat-model.md +161 -0
  172. package/examples/README.md +119 -0
  173. package/examples/express-app/index.ts +747 -0
  174. package/examples/hono-app/docker-compose.yml +14 -0
  175. package/examples/hono-app/index.ts +1009 -0
  176. package/examples/policy/fortress.policy.json +47 -0
  177. package/examples/sveltekit-app/README.md +125 -0
  178. package/examples/sveltekit-app/src/app.d.ts +16 -0
  179. package/examples/sveltekit-app/src/hooks.server.ts +23 -0
  180. package/examples/sveltekit-app/src/lib/server/fortress.ts +81 -0
  181. package/examples/sveltekit-app/src/routes/api/echo/[id]/+server.ts +32 -0
  182. package/examples/sveltekit-app/src/routes/api/fortress/[...path]/+server.ts +15 -0
  183. package/examples/sveltekit-app/src/routes/dashboard/+page.server.ts +20 -0
  184. package/examples/sveltekit-app/src/routes/login/+page.server.ts +48 -0
  185. package/examples/sveltekit-app/src/routes/oauth/consent/+page.server.ts +69 -0
  186. package/examples/sveltekit-app/src/routes/oauth/consent/+page.svelte +83 -0
  187. package/migrations/pg/0001_schema_version.down.sql +2 -0
  188. package/migrations/pg/0001_schema_version.sql +8 -0
  189. package/migrations/pg/0002_initial_schema.down.sql +36 -0
  190. package/migrations/pg/0002_initial_schema.sql +376 -0
  191. package/migrations/pg/0003_auth_continuation.down.sql +6 -0
  192. package/migrations/pg/0003_auth_continuation.sql +30 -0
  193. package/migrations/pg/0004_tenant_default_unique.down.sql +1 -0
  194. package/migrations/pg/0004_tenant_default_unique.sql +14 -0
  195. package/migrations/pg/0005_hot_indexes_timestamptz.down.sql +71 -0
  196. package/migrations/pg/0005_hot_indexes_timestamptz.sql +71 -0
  197. package/migrations/pg/0006_canonical_email.down.sql +3 -0
  198. package/migrations/pg/0006_canonical_email.sql +8 -0
  199. package/migrations/pg/0007_audit_chain_anchor.down.sql +1 -0
  200. package/migrations/pg/0007_audit_chain_anchor.sql +8 -0
  201. package/migrations/pg/0008_two_factor_hardening.down.sql +8 -0
  202. package/migrations/pg/0008_two_factor_hardening.sql +8 -0
  203. package/migrations/pg/0009_encrypt_totp_secrets.down.sql +2 -0
  204. package/migrations/pg/0009_encrypt_totp_secrets.sql +5 -0
  205. package/migrations/pg/0010_bigint_append_only_ids.down.sql +2 -0
  206. package/migrations/pg/0010_bigint_append_only_ids.sql +23 -0
  207. package/migrations/sqlite/0001_schema_version.down.sql +2 -0
  208. package/migrations/sqlite/0001_schema_version.sql +8 -0
  209. package/migrations/sqlite/0002_initial_schema.down.sql +36 -0
  210. package/migrations/sqlite/0002_initial_schema.sql +376 -0
  211. package/migrations/sqlite/0003_auth_continuation.down.sql +27 -0
  212. package/migrations/sqlite/0003_auth_continuation.sql +45 -0
  213. package/migrations/sqlite/0004_tenant_default_unique.down.sql +1 -0
  214. package/migrations/sqlite/0004_tenant_default_unique.sql +13 -0
  215. package/migrations/sqlite/0005_hot_indexes_timestamptz.down.sql +10 -0
  216. package/migrations/sqlite/0005_hot_indexes_timestamptz.sql +10 -0
  217. package/migrations/sqlite/0006_canonical_email.down.sql +3 -0
  218. package/migrations/sqlite/0006_canonical_email.sql +8 -0
  219. package/migrations/sqlite/0007_audit_chain_anchor.down.sql +1 -0
  220. package/migrations/sqlite/0007_audit_chain_anchor.sql +8 -0
  221. package/migrations/sqlite/0008_two_factor_hardening.down.sql +14 -0
  222. package/migrations/sqlite/0008_two_factor_hardening.sql +7 -0
  223. package/migrations/sqlite/0009_encrypt_totp_secrets.down.sql +2 -0
  224. package/migrations/sqlite/0009_encrypt_totp_secrets.sql +5 -0
  225. package/migrations/sqlite/0010_bigint_append_only_ids.down.sql +1 -0
  226. package/migrations/sqlite/0010_bigint_append_only_ids.sql +2 -0
  227. package/package.json +398 -0
  228. package/src/adapters/database/index.ts +63 -0
  229. package/src/adapters/database/types.ts +22 -0
  230. package/src/changelog-script.test.ts +21 -0
  231. package/src/cli.test.ts +79 -0
  232. package/src/core/auth/auth-admin.test.ts +247 -0
  233. package/src/core/auth/auth-endpoints.ts +558 -0
  234. package/src/core/auth/auth-observer.test.ts +191 -0
  235. package/src/core/auth/auth-service.ts +1464 -0
  236. package/src/core/auth/email.test.ts +17 -0
  237. package/src/core/auth/email.ts +11 -0
  238. package/src/core/auth/hooks.test.ts +251 -0
  239. package/src/core/auth/impersonation.test.ts +179 -0
  240. package/src/core/auth/jwt.test.ts +184 -0
  241. package/src/core/auth/jwt.ts +239 -0
  242. package/src/core/auth/login-timing.test.ts +77 -0
  243. package/src/core/auth/password-policy.test.ts +253 -0
  244. package/src/core/auth/password-policy.ts +220 -0
  245. package/src/core/auth/password.test.ts +42 -0
  246. package/src/core/auth/password.ts +62 -0
  247. package/src/core/auth/post-auth-gate.test.ts +258 -0
  248. package/src/core/auth/post-auth-gate.ts +228 -0
  249. package/src/core/auth/refresh-token.test.ts +86 -0
  250. package/src/core/auth/refresh-token.ts +105 -0
  251. package/src/core/auth/timing-safe.ts +23 -0
  252. package/src/core/config.ts +212 -0
  253. package/src/core/endpoint.ts +149 -0
  254. package/src/core/errors.ts +199 -0
  255. package/src/core/fetcher-interop.test.ts +106 -0
  256. package/src/core/fortress.test.ts +354 -0
  257. package/src/core/fortress.ts +550 -0
  258. package/src/core/http/call.test.ts +148 -0
  259. package/src/core/http/call.ts +149 -0
  260. package/src/core/http/cookie-serialize.test.ts +198 -0
  261. package/src/core/http/cookie-serialize.ts +107 -0
  262. package/src/core/http/csrf.test.ts +140 -0
  263. package/src/core/http/csrf.ts +173 -0
  264. package/src/core/http/dispatch.ts +652 -0
  265. package/src/core/http/error-response.test.ts +69 -0
  266. package/src/core/http/error-response.ts +93 -0
  267. package/src/core/http/fortress-rbac.test.ts +43 -0
  268. package/src/core/http/fortress-rbac.ts +127 -0
  269. package/src/core/http/handle-request.test.ts +441 -0
  270. package/src/core/http/handle-request.ts +354 -0
  271. package/src/core/http/match.test.ts +122 -0
  272. package/src/core/http/match.ts +126 -0
  273. package/src/core/http/outbound.test.ts +34 -0
  274. package/src/core/http/outbound.ts +105 -0
  275. package/src/core/http/plugin-middleware.ts +67 -0
  276. package/src/core/http/principal.test.ts +345 -0
  277. package/src/core/http/principal.ts +86 -0
  278. package/src/core/http/protect.test.ts +220 -0
  279. package/src/core/http/protect.ts +394 -0
  280. package/src/core/http/token-extraction.test.ts +59 -0
  281. package/src/core/http/token-extraction.ts +53 -0
  282. package/src/core/iam/explain.test.ts +177 -0
  283. package/src/core/iam/explain.ts +302 -0
  284. package/src/core/iam/iam-endpoints.ts +779 -0
  285. package/src/core/iam/iam-service.test.ts +829 -0
  286. package/src/core/iam/iam-service.ts +1137 -0
  287. package/src/core/iam/permission-cache.test.ts +115 -0
  288. package/src/core/iam/permission-cache.ts +71 -0
  289. package/src/core/iam/permission-check-observer.test.ts +90 -0
  290. package/src/core/iam/permission-evaluator.test.ts +291 -0
  291. package/src/core/iam/permission-evaluator.ts +198 -0
  292. package/src/core/iam/permission-sync.test.ts +166 -0
  293. package/src/core/iam/permission-sync.ts +192 -0
  294. package/src/core/iam/resource-sync.test.ts +70 -0
  295. package/src/core/iam/resource-sync.ts +182 -0
  296. package/src/core/iam/service-account-http.test.ts +529 -0
  297. package/src/core/iam/service-account.test.ts +282 -0
  298. package/src/core/internal-adapter.test.ts +389 -0
  299. package/src/core/internal-adapter.ts +435 -0
  300. package/src/core/json-schema.ts +50 -0
  301. package/src/core/manifest/__snapshots__/route-manifest.test.ts.snap +192 -0
  302. package/src/core/manifest/drift.ts +103 -0
  303. package/src/core/manifest/route-manifest.test.ts +142 -0
  304. package/src/core/manifest/route-manifest.ts +154 -0
  305. package/src/core/migrate.test.ts +72 -0
  306. package/src/core/migrations/engine.test.ts +308 -0
  307. package/src/core/migrations/engine.ts +548 -0
  308. package/src/core/migrations/migrations.ts +1771 -0
  309. package/src/core/migrations/pg-migration.integration-test.ts +353 -0
  310. package/src/core/migrations/upgrade-fixture.test.ts +378 -0
  311. package/src/core/observability/db-instrumentation.ts +205 -0
  312. package/src/core/observability/listener-list.test.ts +124 -0
  313. package/src/core/observability/listener-list.ts +73 -0
  314. package/src/core/observability/logger.test.ts +43 -0
  315. package/src/core/observability/logger.ts +49 -0
  316. package/src/core/observability/spans.test.ts +193 -0
  317. package/src/core/observability/types.ts +80 -0
  318. package/src/core/openapi-drift.test.ts +41 -0
  319. package/src/core/openapi.ts +47 -0
  320. package/src/core/plugin-methods-map.ts +75 -0
  321. package/src/core/plugin-runner.test.ts +233 -0
  322. package/src/core/plugin-runner.ts +276 -0
  323. package/src/core/plugin.ts +210 -0
  324. package/src/core/policy/apply.ts +272 -0
  325. package/src/core/policy/diff.ts +288 -0
  326. package/src/core/policy/loader.test.ts +63 -0
  327. package/src/core/policy/loader.ts +214 -0
  328. package/src/core/policy/policy.test.ts +232 -0
  329. package/src/core/policy/types.ts +105 -0
  330. package/src/core/schema-builder.test.ts +660 -0
  331. package/src/core/schema-builder.ts +881 -0
  332. package/src/core/standard-schema.ts +56 -0
  333. package/src/core/types.ts +258 -0
  334. package/src/core/validation.test.ts +195 -0
  335. package/src/core/validation.ts +192 -0
  336. package/src/drizzle/adapter.test.ts +425 -0
  337. package/src/drizzle/adapter.ts +474 -0
  338. package/src/drizzle/index.ts +31 -0
  339. package/src/drizzle/pg/adapter.integration-test.ts +456 -0
  340. package/src/drizzle/pg/index.ts +13 -0
  341. package/src/drizzle/pg/pg.integration-test.ts +1539 -0
  342. package/src/drizzle/pg/schema.ts +539 -0
  343. package/src/drizzle/pg-error-map.test.ts +218 -0
  344. package/src/drizzle/pg-error-map.ts +151 -0
  345. package/src/drizzle/schema.ts +544 -0
  346. package/src/drizzle/sqlite-serialization.test.ts +106 -0
  347. package/src/express/express-api-key-user-routes.test.ts +241 -0
  348. package/src/express/express.test.ts +442 -0
  349. package/src/express/handle.ts +191 -0
  350. package/src/express/index.ts +62 -0
  351. package/src/express/middleware.ts +551 -0
  352. package/src/express/protect.ts +154 -0
  353. package/src/express/validated.test.ts +86 -0
  354. package/src/express/validated.ts +99 -0
  355. package/src/fetcher/index.ts +81 -0
  356. package/src/hono/convert-routes.test.ts +220 -0
  357. package/src/hono/convert-routes.ts +196 -0
  358. package/src/hono/converters.test.ts +50 -0
  359. package/src/hono/converters.ts +43 -0
  360. package/src/hono/handle.ts +79 -0
  361. package/src/hono/helpers.ts +2 -0
  362. package/src/hono/hono-api-key-user-routes.test.ts +225 -0
  363. package/src/hono/hono-handlers.test.ts +861 -0
  364. package/src/hono/hono.test.ts +454 -0
  365. package/src/hono/index.ts +101 -0
  366. package/src/hono/middleware/auth.ts +236 -0
  367. package/src/hono/middleware/auth.types.test.ts +94 -0
  368. package/src/hono/middleware/csrf.test.ts +127 -0
  369. package/src/hono/middleware/csrf.ts +68 -0
  370. package/src/hono/middleware/error-handler.ts +12 -0
  371. package/src/hono/middleware/plugin-middleware.ts +35 -0
  372. package/src/hono/middleware/rbac.ts +131 -0
  373. package/src/hono/middleware/security-headers.test.ts +88 -0
  374. package/src/hono/middleware/security-headers.ts +68 -0
  375. package/src/hono/openapi.ts +237 -0
  376. package/src/hono/protect.ts +87 -0
  377. package/src/hono/validated.test.ts +123 -0
  378. package/src/hono/validated.ts +62 -0
  379. package/src/index.ts +309 -0
  380. package/src/integration.test.ts +718 -0
  381. package/src/internal/changelog.ts +56 -0
  382. package/src/otel/index.ts +158 -0
  383. package/src/otel/otel-types.test.ts +35 -0
  384. package/src/otel/otel.test.ts +235 -0
  385. package/src/plugins/account-lockout/account-lockout.test.ts +367 -0
  386. package/src/plugins/account-lockout/index.ts +267 -0
  387. package/src/plugins/admin/admin-api-key.test.ts +438 -0
  388. package/src/plugins/admin/index.ts +1612 -0
  389. package/src/plugins/api-key/api-key.test.ts +684 -0
  390. package/src/plugins/api-key/core.ts +336 -0
  391. package/src/plugins/api-key/index.ts +315 -0
  392. package/src/plugins/audit-log/audit-log.test.ts +749 -0
  393. package/src/plugins/audit-log/index.ts +680 -0
  394. package/src/plugins/data-isolation/data-isolation.test.ts +197 -0
  395. package/src/plugins/data-isolation/index.ts +157 -0
  396. package/src/plugins/email-verification/email-verification.test.ts +199 -0
  397. package/src/plugins/email-verification/index.ts +190 -0
  398. package/src/plugins/magic-link/index.ts +129 -0
  399. package/src/plugins/magic-link/magic-link.test.ts +156 -0
  400. package/src/plugins/oauth/id-token.ts +86 -0
  401. package/src/plugins/oauth/index.ts +2145 -0
  402. package/src/plugins/oauth/jwks.test.ts +32 -0
  403. package/src/plugins/oauth/jwks.ts +182 -0
  404. package/src/plugins/oauth/oauth.test.ts +2220 -0
  405. package/src/plugins/oauth/pkce.ts +58 -0
  406. package/src/plugins/openapi/index.ts +298 -0
  407. package/src/plugins/openapi/openapi.test.ts +425 -0
  408. package/src/plugins/openapi/spec-builder.ts +287 -0
  409. package/src/plugins/rate-limit/express.test.ts +89 -0
  410. package/src/plugins/rate-limit/express.ts +86 -0
  411. package/src/plugins/rate-limit/hono.test.ts +60 -0
  412. package/src/plugins/rate-limit/hono.ts +74 -0
  413. package/src/plugins/rate-limit/index.ts +383 -0
  414. package/src/plugins/rate-limit/memory-store.ts +61 -0
  415. package/src/plugins/rate-limit/rate-limit.test.ts +756 -0
  416. package/src/plugins/rate-limit/sveltekit.test.ts +58 -0
  417. package/src/plugins/rate-limit/sveltekit.ts +66 -0
  418. package/src/plugins/social-login/index.ts +715 -0
  419. package/src/plugins/social-login/providers/apple.ts +34 -0
  420. package/src/plugins/social-login/providers/discord.ts +26 -0
  421. package/src/plugins/social-login/providers/github.ts +54 -0
  422. package/src/plugins/social-login/providers/google.ts +23 -0
  423. package/src/plugins/social-login/providers/index.ts +22 -0
  424. package/src/plugins/social-login/providers/microsoft.ts +35 -0
  425. package/src/plugins/social-login/providers/oidc.ts +37 -0
  426. package/src/plugins/social-login/providers/providers.test.ts +211 -0
  427. package/src/plugins/social-login/social-login.test.ts +675 -0
  428. package/src/plugins/social-login/types.ts +80 -0
  429. package/src/plugins/tenancy/index.ts +544 -0
  430. package/src/plugins/tenancy/tenancy.test.ts +323 -0
  431. package/src/plugins/two-factor/index.ts +569 -0
  432. package/src/plugins/two-factor/two-factor.test.ts +235 -0
  433. package/src/plugins/webauthn/index.ts +554 -0
  434. package/src/plugins/webauthn/webauthn.test.ts +472 -0
  435. package/src/plugins/webhook/builtin-events.ts +29 -0
  436. package/src/plugins/webhook/delivery.test.ts +51 -0
  437. package/src/plugins/webhook/delivery.ts +154 -0
  438. package/src/plugins/webhook/hooks.ts +89 -0
  439. package/src/plugins/webhook/index.ts +445 -0
  440. package/src/plugins/webhook/queue/database.ts +67 -0
  441. package/src/plugins/webhook/queue/in-memory.test.ts +48 -0
  442. package/src/plugins/webhook/queue/in-memory.ts +71 -0
  443. package/src/plugins/webhook/queue/types.ts +51 -0
  444. package/src/plugins/webhook/registry.test.ts +48 -0
  445. package/src/plugins/webhook/registry.ts +64 -0
  446. package/src/plugins/webhook/signing.ts +45 -0
  447. package/src/plugins/webhook/ssrf.ts +198 -0
  448. package/src/plugins/webhook/types.ts +138 -0
  449. package/src/plugins/webhook/webhook.test.ts +324 -0
  450. package/src/sveltekit/actions.ts +233 -0
  451. package/src/sveltekit/catch-all.ts +43 -0
  452. package/src/sveltekit/cookies.ts +136 -0
  453. package/src/sveltekit/handle.ts +356 -0
  454. package/src/sveltekit/helpers.ts +69 -0
  455. package/src/sveltekit/index.ts +74 -0
  456. package/src/sveltekit/protect.ts +80 -0
  457. package/src/sveltekit/sveltekit-types.test.ts +31 -0
  458. package/src/sveltekit/sveltekit.test.ts +799 -0
  459. package/src/sveltekit/types.ts +134 -0
  460. package/src/sveltekit/validated.test.ts +117 -0
  461. package/src/sveltekit/validated.ts +78 -0
  462. package/src/testing/adapter-conformance.test.ts +408 -0
  463. package/src/testing/checks.test.ts +124 -0
  464. package/src/testing/checks.ts +304 -0
  465. package/src/testing/index.ts +107 -0
@@ -0,0 +1,1137 @@
1
+ import type { DatabaseAdapter, WhereClause } from '../../adapters/database';
2
+ import type { FortressConfig } from '../config';
3
+ import type { Unsubscribe } from '../observability/listener-list';
4
+ import type { FortressLogger } from '../observability/logger';
5
+ import type { TelemetryProvider } from '../observability/types';
6
+ import type {
7
+ CreateServiceAccountInput,
8
+ FortressUser,
9
+ Group,
10
+ Permission,
11
+ PermissionContext,
12
+ PermissionInput,
13
+ Role,
14
+ RoleBinding,
15
+ ServiceAccount,
16
+ Subject,
17
+ SubjectType,
18
+ } from '../types';
19
+ import type { EvaluationMode } from './permission-evaluator';
20
+ import type { ResourceFile } from './resource-sync';
21
+ import { Errors } from '../errors';
22
+ import { createInternalAdapter } from '../internal-adapter';
23
+ import { createListenerList } from '../observability/listener-list';
24
+ import { SILENT_LOGGER } from '../observability/logger';
25
+ import { createPermissionCache, subjectCacheKey } from './permission-cache';
26
+ import { evaluatePermissions, withinCredentialScope } from './permission-evaluator';
27
+ import { loadResourceFile, pullResources, pushResources, writeResourceFile } from './resource-sync';
28
+
29
+ export interface IamEvent {
30
+ eventType: string;
31
+ actorId?: string | null;
32
+ targetId?: string | null;
33
+ targetType?: string | null;
34
+ metadata?: Record<string, unknown>;
35
+ }
36
+
37
+ /**
38
+ * Async IAM event listener. May return a Promise — if you `return` it, any
39
+ * rejection is routed to `config.logger.error`. Firing work via
40
+ * `void asyncWork()` inside a sync body is an explicit opt-out of that
41
+ * safety net; rejections will escape to the runtime's unhandled-rejection
42
+ * handler instead.
43
+ */
44
+ export type IamEventListener = (event: IamEvent) => void | Promise<void>;
45
+
46
+ /**
47
+ * High-frequency event fired on every permission check. Emitted by a
48
+ * separate observer list from {@link IamEvent} so audit-log consumers
49
+ * (which subscribe to mutations) aren't spammed with per-check traffic.
50
+ *
51
+ * Listeners are invoked **synchronously**. The type signature intentionally
52
+ * returns `void` (not `Promise<void>`) to discourage awaiting expensive
53
+ * work on the hot path. If an observer needs async work it should fire
54
+ * and forget with `void asyncWork()`.
55
+ */
56
+ export interface PermissionCheckEvent {
57
+ subjectType: 'USER' | 'SERVICE_ACCOUNT' | 'GROUP';
58
+ subjectId: string;
59
+ resource: string;
60
+ action: string;
61
+ allowed: boolean;
62
+ cached: boolean;
63
+ /** Pre-divided monotonic duration in seconds; ready to feed into a histogram. */
64
+ durationSeconds: number;
65
+ tenantId?: string;
66
+ }
67
+
68
+ export type PermissionCheckListener = (event: PermissionCheckEvent) => void;
69
+
70
+ export interface IamService {
71
+ checkPermission: (subject: Subject, resource: string, action: string, context?: PermissionContext) => Promise<boolean>;
72
+ getPermissionsForSubject: (subject: Subject, tenantId?: string) => Promise<Permission[]>;
73
+ createRole: (name: string, permissions: PermissionInput[], description?: string) => Promise<Role>;
74
+ deleteRole: (roleId: string) => Promise<void>;
75
+ bindRole: (subjectType: SubjectType, subjectId: string, roleId: string, tenantId?: string) => Promise<void>;
76
+ bindRoleToUser: (userId: string, roleId: string, tenantId?: string) => Promise<void>;
77
+ bindRoleToGroup: (groupId: string, roleId: string, tenantId?: string) => Promise<void>;
78
+ /** Omitted removes all scopes; null removes only the global binding; a string removes one tenant. */
79
+ unbindRole: (subjectType: SubjectType, subjectId: string, roleId: string, tenantId?: string | null) => Promise<void>;
80
+ bindPermissionToUser: (userId: string, permission: PermissionInput, tenantId?: string) => Promise<void>;
81
+ bindPermissionToGroup: (groupId: string, permission: PermissionInput, tenantId?: string) => Promise<void>;
82
+ unbindPermissionFromUser: (userId: string, permissionId: string, tenantId?: string | null) => Promise<void>;
83
+ unbindPermissionFromGroup: (groupId: string, permissionId: string, tenantId?: string | null) => Promise<void>;
84
+ createGroup: (name: string, description?: string) => Promise<Group>;
85
+ addUserToGroup: (groupId: string, userId: string) => Promise<void>;
86
+ removeUserFromGroup: (groupId: string, userId: string) => Promise<void>;
87
+ getResources: () => Promise<ResourceFile>;
88
+ getRoles: () => Promise<Role[]>;
89
+ /** Apply a resource map directly without filesystem access. */
90
+ pushResources: (resources: ResourceFile) => Promise<void>;
91
+ /** List role bindings: omitted scope means all, `null` means global only, string means one tenant. */
92
+ listRoleBindingsForSubject: (subject: Subject, tenantId?: string | null) => Promise<RoleBinding[]>;
93
+ syncResources: (direction: 'push' | 'pull', filePath?: string) => Promise<void>;
94
+ clearPermissionCache: () => void;
95
+ /**
96
+ * Register a listener for IAM mutation events. Multiple listeners are
97
+ * supported — each is invoked in registration order. Observer failures
98
+ * are routed to the configured logger at `error` level and never break
99
+ * IAM operations. Returns an unsubscribe function.
100
+ */
101
+ addIamObserver: (listener: IamEventListener) => Unsubscribe;
102
+
103
+ /**
104
+ * Register a listener for individual permission checks. Fires on every
105
+ * `checkPermission` call with latency and cache-hit information. The
106
+ * listener signature is synchronous — see {@link PermissionCheckEvent}
107
+ * for rationale. Returns an unsubscribe function.
108
+ */
109
+ addPermissionCheckObserver: (listener: PermissionCheckListener) => Unsubscribe;
110
+
111
+ // ── Admin CRUD ─────────────────────────────────────────────────
112
+ getRole: (roleId: string) => Promise<Role & { permissions: Permission[] }>;
113
+ updateRole: (roleId: string, data: { name?: string; description?: string | null }) => Promise<Role>;
114
+ listGroups: (options?: { limit?: number; offset?: number }) => Promise<{ groups: Group[]; total: number }>;
115
+ getGroup: (groupId: string) => Promise<Group & { users: FortressUser[] }>;
116
+ updateGroup: (groupId: string, data: { name?: string; description?: string }) => Promise<Group>;
117
+ deleteGroup: (groupId: string) => Promise<void>;
118
+ getGroupUsers: (groupId: string) => Promise<FortressUser[]>;
119
+ listPermissions: (options?: { resource?: string }) => Promise<Permission[]>;
120
+ createPermission: (permission: PermissionInput) => Promise<Permission>;
121
+ deletePermission: (permissionId: string) => Promise<void>;
122
+ addPermissionToRole: (roleId: string, permission: PermissionInput) => Promise<void>;
123
+ removePermissionFromRole: (roleId: string, permission: PermissionInput) => Promise<void>;
124
+
125
+ // ── Service Accounts ───────────────────────────────────────────
126
+ createServiceAccount: (input: CreateServiceAccountInput) => Promise<ServiceAccount>;
127
+ getServiceAccount: (id: string) => Promise<ServiceAccount>;
128
+ listServiceAccounts: (options?: { limit?: number; offset?: number }) => Promise<{ serviceAccounts: ServiceAccount[]; total: number }>;
129
+ updateServiceAccount: (id: string, data: { displayName?: string | null; description?: string | null; isActive?: boolean }) => Promise<ServiceAccount>;
130
+ deleteServiceAccount: (id: string) => Promise<void>;
131
+ bindRoleToServiceAccount: (serviceAccountId: string, roleId: string, tenantId?: string) => Promise<void>;
132
+ /** Unbind across all scopes when omitted, global only for `null`, or one tenant for a string scope. */
133
+ unbindRoleFromServiceAccount: (serviceAccountId: string, roleId: string, tenantId?: string | null) => Promise<void>;
134
+ bindPermissionToServiceAccount: (serviceAccountId: string, permission: PermissionInput, tenantId?: string) => Promise<void>;
135
+ unbindPermissionFromServiceAccount: (serviceAccountId: string, permissionId: string, tenantId?: string | null) => Promise<void>;
136
+ }
137
+
138
+ export interface IamServiceDeps {
139
+ logger: FortressLogger;
140
+ telemetry: TelemetryProvider;
141
+ }
142
+
143
+ export function createIamService(
144
+ db: DatabaseAdapter,
145
+ config: FortressConfig,
146
+ deps?: IamServiceDeps,
147
+ ): IamService {
148
+ const evaluationMode: EvaluationMode = config.rbac?.evaluationMode ?? 'allow-only';
149
+ const resourceFile = config.rbac?.resourceFile ?? './fortress.resources.json';
150
+ const adapter = createInternalAdapter(db);
151
+ const logger = deps?.logger;
152
+ const telemetry = deps?.telemetry;
153
+
154
+ const iamEventListeners = createListenerList<IamEvent>({
155
+ kind: 'async',
156
+ eventLabel: 'iam',
157
+ logger: () => logger ?? SILENT_LOGGER,
158
+ });
159
+ const permissionCheckListeners = createListenerList<PermissionCheckEvent>({
160
+ kind: 'sync',
161
+ eventLabel: 'iam.permission_check',
162
+ logger: () => logger ?? SILENT_LOGGER,
163
+ });
164
+
165
+ function emit(event: IamEvent): void {
166
+ iamEventListeners.emit(event);
167
+ }
168
+ const cacheConfig = config.rbac?.cache;
169
+ const cache = cacheConfig
170
+ ? createPermissionCache(
171
+ (cacheConfig.ttlSeconds ?? 30) * 1000,
172
+ cacheConfig.maxEntries ?? 1000,
173
+ )
174
+ : null;
175
+
176
+ function tenantWhere(tenantId?: string): { field: 'tenantId'; operator: '=' | 'isNull'; value: string | null }[] {
177
+ return tenantId == null
178
+ ? [{ field: 'tenantId', operator: 'isNull', value: null }]
179
+ : [{ field: 'tenantId', operator: '=', value: tenantId }];
180
+ }
181
+
182
+ function unbindTenantWhere(tenantId?: string | null): WhereClause[] {
183
+ if (tenantId === undefined)
184
+ return [];
185
+ return tenantId === null
186
+ ? [{ field: 'tenantId', operator: 'isNull', value: null }]
187
+ : [{ field: 'tenantId', operator: '=', value: tenantId }];
188
+ }
189
+
190
+ /**
191
+ * Race-safe find-then-create for the idempotency helpers below. The unique
192
+ * indexes guarantee no duplicate row; under genuine concurrency the losing
193
+ * INSERT throws a unique-constraint error, which we treat as "the winner
194
+ * already created it" after re-checking. Returns `true` if THIS call
195
+ * inserted the row, `false` if it already existed (before or via the race).
196
+ */
197
+ async function insertIfMissing(
198
+ database: DatabaseAdapter,
199
+ model: string,
200
+ where: WhereClause[],
201
+ data: Record<string, unknown>,
202
+ ): Promise<boolean> {
203
+ const existing = await database.findOne<{ id?: string }>({ model, where });
204
+ if (existing)
205
+ return false;
206
+ try {
207
+ await database.create({ model, data });
208
+ return true;
209
+ }
210
+ catch (err) {
211
+ const winner = await database.findOne<{ id?: string }>({ model, where });
212
+ if (winner)
213
+ return false;
214
+ throw err;
215
+ }
216
+ }
217
+
218
+ async function createRoleBindingIfMissing(
219
+ database: DatabaseAdapter,
220
+ subjectType: SubjectType,
221
+ subjectId: string,
222
+ roleId: string,
223
+ tenantId?: string,
224
+ ): Promise<boolean> {
225
+ return insertIfMissing(
226
+ database,
227
+ 'role_binding',
228
+ [
229
+ { field: 'roleId', operator: '=', value: roleId },
230
+ { field: 'subjectType', operator: '=', value: subjectType },
231
+ { field: 'subjectId', operator: '=', value: subjectId },
232
+ ...tenantWhere(tenantId),
233
+ ],
234
+ { roleId, subjectType, subjectId, tenantId: tenantId ?? null },
235
+ );
236
+ }
237
+
238
+ async function createDirectPermissionBindingIfMissing(
239
+ database: DatabaseAdapter,
240
+ subjectType: SubjectType,
241
+ subjectId: string,
242
+ permissionId: string,
243
+ tenantId?: string,
244
+ ): Promise<boolean> {
245
+ return insertIfMissing(
246
+ database,
247
+ 'direct_permission_binding',
248
+ [
249
+ { field: 'permissionId', operator: '=', value: permissionId },
250
+ { field: 'subjectType', operator: '=', value: subjectType },
251
+ { field: 'subjectId', operator: '=', value: subjectId },
252
+ ...tenantWhere(tenantId),
253
+ ],
254
+ { permissionId, subjectType, subjectId, tenantId: tenantId ?? null },
255
+ );
256
+ }
257
+
258
+ async function addUserToGroupIfMissing(
259
+ database: DatabaseAdapter,
260
+ groupId: string,
261
+ userId: string,
262
+ ): Promise<boolean> {
263
+ return insertIfMissing(
264
+ database,
265
+ 'group_user',
266
+ [
267
+ { field: 'groupId', operator: '=', value: groupId },
268
+ { field: 'userId', operator: '=', value: userId },
269
+ ],
270
+ { groupId, userId },
271
+ );
272
+ }
273
+
274
+ // Serialize lifecycle-sensitive polymorphic bindings with deletion. The
275
+ // process-local queue covers every adapter; PostgreSQL additionally takes a
276
+ // transaction advisory lock so separate workers/replicas use the same key.
277
+ const mutationQueues = new Map<string, Promise<void>>();
278
+ async function withMutationLock<T>(
279
+ key: string,
280
+ operation: (tx: DatabaseAdapter) => Promise<T>,
281
+ ): Promise<T> {
282
+ const previous = mutationQueues.get(key) ?? Promise.resolve();
283
+ let release!: () => void;
284
+ const current = new Promise<void>((resolve) => {
285
+ release = resolve;
286
+ });
287
+ mutationQueues.set(key, current);
288
+ await previous;
289
+ try {
290
+ return await db.transaction(async (tx) => {
291
+ if (tx.dialect === 'pg' && tx.rawQuery) {
292
+ await tx.rawQuery('SELECT pg_advisory_xact_lock(hashtext(?))', [`fortress:${key}`]);
293
+ }
294
+ return operation(tx);
295
+ });
296
+ }
297
+ finally {
298
+ release();
299
+ if (mutationQueues.get(key) === current)
300
+ mutationQueues.delete(key);
301
+ }
302
+ }
303
+
304
+ async function withExistingGroup<T>(
305
+ groupId: string,
306
+ operation: (tx: DatabaseAdapter, group: Group) => Promise<T>,
307
+ ): Promise<T> {
308
+ return withMutationLock(`group:${groupId}`, async (tx) => {
309
+ const group = await tx.findOne<Group>({
310
+ model: 'group',
311
+ where: [{ field: 'id', operator: '=', value: groupId }],
312
+ });
313
+ if (!group)
314
+ throw Errors.notFound('Group not found');
315
+ return operation(tx, group);
316
+ });
317
+ }
318
+
319
+ return {
320
+ async checkPermission(
321
+ subject: Subject,
322
+ resource: string,
323
+ action: string,
324
+ context?: PermissionContext,
325
+ ): Promise<boolean> {
326
+ const start = performance.now();
327
+ const tenantId = context?.tenantId;
328
+ const cacheKey = subjectCacheKey(subject);
329
+ let permissions: Permission[];
330
+ let cached = false;
331
+
332
+ // USER activation is security state, not permission state. Re-check it
333
+ // before consulting the permission cache so a cached ALLOW cannot survive
334
+ // account deactivation/deletion (including changes made by another
335
+ // process that cannot invalidate this in-memory cache).
336
+ let activeUser = true;
337
+ if (subject.type === 'USER') {
338
+ const user = await db.findOne<Pick<FortressUser, 'isActive'>>({
339
+ model: 'user',
340
+ where: [{ field: 'id', operator: '=', value: subject.id }],
341
+ });
342
+ activeUser = user?.isActive === true;
343
+ if (!activeUser)
344
+ cache?.invalidate(cacheKey);
345
+ }
346
+
347
+ if (!activeUser) {
348
+ permissions = [];
349
+ }
350
+ else if (tenantId) {
351
+ // Tenant-scoped: bypass cache (tenant varies per request)
352
+ permissions = await adapter.getSubjectPermissions(subject, tenantId);
353
+ }
354
+ else {
355
+ // Global: use cache
356
+ const fromCache = cache?.get(cacheKey);
357
+ if (fromCache !== undefined) {
358
+ permissions = fromCache;
359
+ cached = true;
360
+ }
361
+ else {
362
+ const generation = cache?.generation(cacheKey);
363
+ permissions = await adapter.getSubjectPermissions(subject);
364
+ cache?.set(cacheKey, permissions, generation);
365
+ }
366
+ }
367
+
368
+ // Enrich context with subject info. USER subjects still populate
369
+ // `user.id` for backwards-compatible condition expressions; other
370
+ // subject types are exposed under `user.subjectType`/`user.subjectId`.
371
+ const enrichedContext: PermissionContext = {
372
+ ...context,
373
+ user: {
374
+ ...context?.user,
375
+ id: subject.id,
376
+ subjectType: subject.type,
377
+ subjectId: subject.id,
378
+ },
379
+ };
380
+
381
+ const allowed = withinCredentialScope(context?.credentialScopes, resource, action)
382
+ && evaluatePermissions(permissions, resource, action, evaluationMode, enrichedContext);
383
+ const durationSeconds = (performance.now() - start) / 1000;
384
+
385
+ // Span only on deny — allow is metric fodder, deny is security-interesting.
386
+ if (!allowed && telemetry) {
387
+ const span = telemetry.tracer.startSpan('fortress.iam.permission_check.deny', {
388
+ 'subject.type': subject.type,
389
+ 'subject.id': subject.id,
390
+ 'resource': resource,
391
+ 'action': action,
392
+ 'cached': cached,
393
+ });
394
+ span.end();
395
+ }
396
+
397
+ // Observer — only allocate the event object if somebody listens.
398
+ if (permissionCheckListeners.size() > 0) {
399
+ permissionCheckListeners.emit({
400
+ subjectType: subject.type,
401
+ subjectId: subject.id,
402
+ resource,
403
+ action,
404
+ allowed,
405
+ cached,
406
+ durationSeconds,
407
+ tenantId,
408
+ });
409
+ }
410
+
411
+ return allowed;
412
+ },
413
+
414
+ async getPermissionsForSubject(subject: Subject, tenantId?: string): Promise<Permission[]> {
415
+ return adapter.getSubjectPermissions(subject, tenantId);
416
+ },
417
+
418
+ async createRole(name: string, permissions: PermissionInput[], description?: string): Promise<Role> {
419
+ const role = await db.create<Role>({
420
+ model: 'role',
421
+ data: { name, description: description ?? null },
422
+ });
423
+
424
+ for (const perm of permissions) {
425
+ await adapter.ensureResource(perm.resource);
426
+ const permission = await adapter.findOrCreatePermission(perm);
427
+
428
+ await db.create({
429
+ model: 'role_permission',
430
+ data: { roleId: role.id, permissionId: permission.id },
431
+ });
432
+ }
433
+
434
+ emit({ eventType: 'ROLE_CREATED', targetId: role.id, targetType: 'role', metadata: { name, permissions } });
435
+ return role;
436
+ },
437
+
438
+ async deleteRole(roleId: string): Promise<void> {
439
+ const role = await db.findOne<Role & { isSystem?: boolean }>({
440
+ model: 'role',
441
+ where: [{ field: 'id', operator: '=', value: roleId }],
442
+ });
443
+ if (role?.isSystem) {
444
+ throw Errors.badRequest('Cannot delete a system role');
445
+ }
446
+
447
+ // Remove role bindings and role permissions first
448
+ await db.delete({ model: 'role_permission', where: [{ field: 'roleId', operator: '=', value: roleId }] });
449
+ await db.delete({ model: 'role_binding', where: [{ field: 'roleId', operator: '=', value: roleId }] });
450
+ await db.delete({ model: 'role', where: [{ field: 'id', operator: '=', value: roleId }] });
451
+ // M6 fix: a deleted role can affect any subject via group bindings.
452
+ // We don't know which subjects were bound to it across tenants, so a
453
+ // global cache invalidation is the only safe option — without this,
454
+ // a stale ALLOW could survive in cache for up to `cache.ttlSeconds`.
455
+ cache?.invalidateAll();
456
+ emit({ eventType: 'ROLE_DELETED', targetId: roleId, targetType: 'role' });
457
+ },
458
+
459
+ async bindRole(subjectType: SubjectType, subjectId: string, roleId: string, tenantId?: string): Promise<void> {
460
+ const inserted = subjectType === 'GROUP'
461
+ ? await withExistingGroup(subjectId, tx => createRoleBindingIfMissing(tx, subjectType, subjectId, roleId, tenantId))
462
+ : await createRoleBindingIfMissing(db, subjectType, subjectId, roleId, tenantId);
463
+ if (!inserted)
464
+ return;
465
+ if (subjectType === 'USER')
466
+ cache?.invalidate(subjectCacheKey({ type: 'USER', id: subjectId }));
467
+ else if (subjectType === 'SERVICE_ACCOUNT')
468
+ cache?.invalidate(subjectCacheKey({ type: 'SERVICE_ACCOUNT', id: subjectId }));
469
+ else
470
+ cache?.invalidateAll();
471
+ emit({ eventType: 'ROLE_BOUND', targetId: roleId, targetType: 'role', metadata: { subjectType, subjectId, tenantId } });
472
+ },
473
+
474
+ async bindRoleToUser(userId: string, roleId: string, tenantId?: string): Promise<void> {
475
+ const inserted = await createRoleBindingIfMissing(db, 'USER', userId, roleId, tenantId);
476
+ if (!inserted)
477
+ return;
478
+ cache?.invalidate(subjectCacheKey({ type: 'USER', id: userId }));
479
+ emit({ eventType: 'ROLE_BOUND', actorId: userId, targetId: roleId, targetType: 'role', metadata: { subjectType: 'USER', tenantId } });
480
+ },
481
+
482
+ async bindRoleToGroup(groupId: string, roleId: string, tenantId?: string): Promise<void> {
483
+ const inserted = await withExistingGroup(
484
+ groupId,
485
+ tx => createRoleBindingIfMissing(tx, 'GROUP', groupId, roleId, tenantId),
486
+ );
487
+ if (!inserted)
488
+ return;
489
+ cache?.invalidateAll();
490
+ emit({ eventType: 'ROLE_BOUND', targetId: roleId, targetType: 'role', metadata: { subjectType: 'GROUP', groupId, tenantId } });
491
+ },
492
+
493
+ async unbindRole(subjectType: SubjectType, subjectId: string, roleId: string, tenantId?: string | null): Promise<void> {
494
+ const where = [
495
+ { field: 'roleId' as const, operator: '=' as const, value: roleId },
496
+ { field: 'subjectType' as const, operator: '=' as const, value: subjectType },
497
+ { field: 'subjectId' as const, operator: '=' as const, value: subjectId },
498
+ ...unbindTenantWhere(tenantId),
499
+ ];
500
+ await db.delete({ model: 'role_binding', where });
501
+ if (subjectType === 'USER')
502
+ cache?.invalidate(subjectCacheKey({ type: 'USER', id: subjectId }));
503
+ else if (subjectType === 'SERVICE_ACCOUNT')
504
+ cache?.invalidate(subjectCacheKey({ type: 'SERVICE_ACCOUNT', id: subjectId }));
505
+ else cache?.invalidateAll();
506
+ emit({ eventType: 'ROLE_UNBOUND', targetId: roleId, targetType: 'role', metadata: { subjectType, subjectId, tenantId } });
507
+ },
508
+
509
+ async bindPermissionToUser(userId: string, permission: PermissionInput, tenantId?: string): Promise<void> {
510
+ await adapter.ensureResource(permission.resource);
511
+ const perm = await adapter.findOrCreatePermission(permission);
512
+ const inserted = await createDirectPermissionBindingIfMissing(db, 'USER', userId, perm.id, tenantId);
513
+ if (!inserted)
514
+ return;
515
+ cache?.invalidate(subjectCacheKey({ type: 'USER', id: userId }));
516
+ emit({ eventType: 'PERMISSION_CHANGED', actorId: userId, targetId: perm.id, targetType: 'permission', metadata: { action: 'bind', subjectType: 'USER', tenantId } });
517
+ },
518
+
519
+ async bindPermissionToGroup(groupId: string, permission: PermissionInput, tenantId?: string): Promise<void> {
520
+ await adapter.ensureResource(permission.resource);
521
+ const perm = await adapter.findOrCreatePermission(permission);
522
+ const inserted = await withExistingGroup(
523
+ groupId,
524
+ tx => createDirectPermissionBindingIfMissing(tx, 'GROUP', groupId, perm.id, tenantId),
525
+ );
526
+ if (!inserted)
527
+ return;
528
+ cache?.invalidateAll();
529
+ emit({ eventType: 'PERMISSION_CHANGED', targetId: perm.id, targetType: 'permission', metadata: { action: 'bind', subjectType: 'GROUP', groupId, tenantId } });
530
+ },
531
+
532
+ async unbindPermissionFromUser(userId: string, permissionId: string, tenantId?: string | null): Promise<void> {
533
+ const where = [
534
+ { field: 'permissionId' as const, operator: '=' as const, value: permissionId },
535
+ { field: 'subjectType' as const, operator: '=' as const, value: 'USER' },
536
+ { field: 'subjectId' as const, operator: '=' as const, value: userId },
537
+ ...unbindTenantWhere(tenantId),
538
+ ];
539
+ await db.delete({ model: 'direct_permission_binding', where });
540
+ cache?.invalidate(subjectCacheKey({ type: 'USER', id: userId }));
541
+ emit({ eventType: 'PERMISSION_CHANGED', actorId: userId, targetId: permissionId, targetType: 'permission', metadata: { action: 'unbind', subjectType: 'USER', tenantId } });
542
+ },
543
+
544
+ async unbindPermissionFromGroup(groupId: string, permissionId: string, tenantId?: string | null): Promise<void> {
545
+ const where = [
546
+ { field: 'permissionId' as const, operator: '=' as const, value: permissionId },
547
+ { field: 'subjectType' as const, operator: '=' as const, value: 'GROUP' },
548
+ { field: 'subjectId' as const, operator: '=' as const, value: groupId },
549
+ ...unbindTenantWhere(tenantId),
550
+ ];
551
+ await db.delete({ model: 'direct_permission_binding', where });
552
+ cache?.invalidateAll();
553
+ emit({ eventType: 'PERMISSION_CHANGED', targetId: permissionId, targetType: 'permission', metadata: { action: 'unbind', subjectType: 'GROUP', groupId, tenantId } });
554
+ },
555
+
556
+ async createGroup(name: string, description?: string): Promise<Group> {
557
+ const group = await db.create<Group>({
558
+ model: 'group',
559
+ data: { name, description: description ?? null },
560
+ });
561
+ emit({ eventType: 'GROUP_CREATED', targetId: group.id, targetType: 'group', metadata: { name } });
562
+ return group;
563
+ },
564
+
565
+ async addUserToGroup(groupId: string, userId: string): Promise<void> {
566
+ const inserted = await withExistingGroup(
567
+ groupId,
568
+ tx => addUserToGroupIfMissing(tx, groupId, userId),
569
+ );
570
+ if (!inserted)
571
+ return;
572
+ cache?.invalidate(subjectCacheKey({ type: 'USER', id: userId }));
573
+ emit({ eventType: 'GROUP_MEMBER_ADDED', actorId: userId, targetId: groupId, targetType: 'group' });
574
+ },
575
+
576
+ async removeUserFromGroup(groupId: string, userId: string): Promise<void> {
577
+ await db.delete({
578
+ model: 'group_user',
579
+ where: [
580
+ { field: 'groupId', operator: '=', value: groupId },
581
+ { field: 'userId', operator: '=', value: userId },
582
+ ],
583
+ });
584
+ cache?.invalidate(subjectCacheKey({ type: 'USER', id: userId }));
585
+ emit({ eventType: 'GROUP_MEMBER_REMOVED', actorId: userId, targetId: groupId, targetType: 'group' });
586
+ },
587
+
588
+ async getResources(): Promise<ResourceFile> {
589
+ return pullResources(db);
590
+ },
591
+
592
+ async getRoles(): Promise<Role[]> {
593
+ return db.findMany<Role>({ model: 'role' });
594
+ },
595
+
596
+ async pushResources(resources: ResourceFile): Promise<void> {
597
+ await pushResources(db, resources);
598
+ },
599
+
600
+ async listRoleBindingsForSubject(subject: Subject, tenantId?: string | null): Promise<RoleBinding[]> {
601
+ const where: WhereClause[] = [
602
+ { field: 'subjectType', operator: '=', value: subject.type },
603
+ { field: 'subjectId', operator: '=', value: subject.id },
604
+ ];
605
+ if (tenantId === null)
606
+ where.push({ field: 'tenantId', operator: 'isNull', value: null });
607
+ else if (tenantId !== undefined)
608
+ where.push({ field: 'tenantId', operator: '=', value: tenantId });
609
+ return db.findMany<RoleBinding>({ model: 'role_binding', where });
610
+ },
611
+
612
+ clearPermissionCache(): void {
613
+ cache?.invalidateAll();
614
+ },
615
+
616
+ addIamObserver(listener: IamEventListener): Unsubscribe {
617
+ return iamEventListeners.add(listener);
618
+ },
619
+
620
+ addPermissionCheckObserver(listener: PermissionCheckListener): Unsubscribe {
621
+ return permissionCheckListeners.add(listener);
622
+ },
623
+
624
+ async syncResources(direction: 'push' | 'pull', filePath?: string): Promise<void> {
625
+ const path = filePath ?? resourceFile;
626
+
627
+ if (direction === 'push') {
628
+ const resources = await loadResourceFile(path);
629
+ if (Object.keys(resources.resources).length === 0) {
630
+ throw Errors.badRequest(`No resources found in ${path}`);
631
+ }
632
+ await pushResources(db, resources);
633
+ }
634
+ else {
635
+ const resources = await pullResources(db);
636
+ await writeResourceFile(path, resources);
637
+ }
638
+ },
639
+
640
+ // ── Admin CRUD ───────────────────────────────────────────────
641
+
642
+ async getRole(roleId: string): Promise<Role & { permissions: Permission[] }> {
643
+ const role = await db.findOne<Role>({
644
+ model: 'role',
645
+ where: [{ field: 'id', operator: '=', value: roleId }],
646
+ });
647
+ if (!role) {
648
+ throw Errors.notFound('Role not found');
649
+ }
650
+
651
+ const rolePerms = await db.findMany<{ permissionId: string }>({
652
+ model: 'role_permission',
653
+ where: [{ field: 'roleId', operator: '=', value: roleId }],
654
+ });
655
+
656
+ let permissions: Permission[] = [];
657
+ if (rolePerms.length > 0) {
658
+ const permIds = rolePerms.map(rp => rp.permissionId);
659
+ permissions = await db.findMany<Permission>({
660
+ model: 'permission',
661
+ where: [{ field: 'id', operator: 'in', value: permIds }],
662
+ });
663
+ }
664
+
665
+ return { ...role, permissions };
666
+ },
667
+
668
+ async updateRole(roleId: string, data: { name?: string; description?: string | null }): Promise<Role> {
669
+ const existing = await db.findOne<Role & { isSystem?: boolean }>({
670
+ model: 'role',
671
+ where: [{ field: 'id', operator: '=', value: roleId }],
672
+ });
673
+ if (!existing) {
674
+ throw Errors.notFound('Role not found');
675
+ }
676
+ if (existing.isSystem) {
677
+ throw Errors.badRequest('Cannot update a system role');
678
+ }
679
+
680
+ const updateData: Record<string, unknown> = {};
681
+ if (data.name !== undefined)
682
+ updateData.name = data.name;
683
+ if (data.description !== undefined)
684
+ updateData.description = data.description;
685
+
686
+ const updated = await db.update<Role>({
687
+ model: 'role',
688
+ where: [{ field: 'id', operator: '=', value: roleId }],
689
+ data: updateData,
690
+ });
691
+
692
+ emit({ eventType: 'ROLE_UPDATED', targetId: roleId, targetType: 'role', metadata: data });
693
+ return updated!;
694
+ },
695
+
696
+ async listGroups(options?: { limit?: number; offset?: number }): Promise<{ groups: Group[]; total: number }> {
697
+ const [groups, total] = await Promise.all([
698
+ db.findMany<Group>({
699
+ model: 'group',
700
+ limit: options?.limit ?? 50,
701
+ offset: options?.offset ?? 0,
702
+ sortBy: { field: 'id', direction: 'asc' },
703
+ }),
704
+ db.count({ model: 'group' }),
705
+ ]);
706
+
707
+ return { groups, total };
708
+ },
709
+
710
+ async getGroup(groupId: string): Promise<Group & { users: FortressUser[] }> {
711
+ const group = await db.findOne<Group>({
712
+ model: 'group',
713
+ where: [{ field: 'id', operator: '=', value: groupId }],
714
+ });
715
+ if (!group) {
716
+ throw Errors.notFound('Group not found');
717
+ }
718
+
719
+ const memberships = await db.findMany<{ userId: string }>({
720
+ model: 'group_user',
721
+ where: [{ field: 'groupId', operator: '=', value: groupId }],
722
+ });
723
+
724
+ let users: FortressUser[] = [];
725
+ if (memberships.length > 0) {
726
+ const userIds = memberships.map(m => m.userId);
727
+ const rawUsers = await db.findMany<FortressUser & { passwordHash?: string }>({
728
+ model: 'user',
729
+ where: [{ field: 'id', operator: 'in', value: userIds }],
730
+ });
731
+ users = rawUsers.map(({ passwordHash: _, ...u }) => u);
732
+ }
733
+
734
+ return { ...group, users };
735
+ },
736
+
737
+ async updateGroup(groupId: string, data: { name?: string; description?: string }): Promise<Group> {
738
+ const existing = await db.findOne<Group>({
739
+ model: 'group',
740
+ where: [{ field: 'id', operator: '=', value: groupId }],
741
+ });
742
+ if (!existing) {
743
+ throw Errors.notFound('Group not found');
744
+ }
745
+
746
+ const updateData: Record<string, unknown> = {};
747
+ if (data.name !== undefined)
748
+ updateData.name = data.name;
749
+ if (data.description !== undefined)
750
+ updateData.description = data.description;
751
+
752
+ const updated = await db.update<Group>({
753
+ model: 'group',
754
+ where: [{ field: 'id', operator: '=', value: groupId }],
755
+ data: updateData,
756
+ });
757
+
758
+ emit({ eventType: 'GROUP_UPDATED', targetId: groupId, targetType: 'group', metadata: data });
759
+ return updated!;
760
+ },
761
+
762
+ async deleteGroup(groupId: string): Promise<void> {
763
+ const existing = await withExistingGroup(groupId, async (tx, group) => {
764
+ // Delete the identity first, then remove polymorphic bindings while
765
+ // holding the shared mutation lock. A concurrent binder either wins
766
+ // before this transaction or observes the group as absent afterwards.
767
+ await tx.delete({ model: 'group', where: [{ field: 'id', operator: '=', value: groupId }] });
768
+ await tx.delete({
769
+ model: 'group_user',
770
+ where: [{ field: 'groupId', operator: '=', value: groupId }],
771
+ });
772
+ await tx.delete({
773
+ model: 'role_binding',
774
+ where: [
775
+ { field: 'subjectType', operator: '=', value: 'GROUP' },
776
+ { field: 'subjectId', operator: '=', value: groupId },
777
+ ],
778
+ });
779
+ await tx.delete({
780
+ model: 'direct_permission_binding',
781
+ where: [
782
+ { field: 'subjectType', operator: '=', value: 'GROUP' },
783
+ { field: 'subjectId', operator: '=', value: groupId },
784
+ ],
785
+ });
786
+ return group;
787
+ });
788
+ cache?.invalidateAll();
789
+ emit({ eventType: 'GROUP_DELETED', targetId: groupId, targetType: 'group', metadata: { name: existing.name } });
790
+ },
791
+
792
+ async getGroupUsers(groupId: string): Promise<FortressUser[]> {
793
+ const memberships = await db.findMany<{ userId: string }>({
794
+ model: 'group_user',
795
+ where: [{ field: 'groupId', operator: '=', value: groupId }],
796
+ });
797
+
798
+ if (memberships.length === 0)
799
+ return [];
800
+
801
+ const userIds = memberships.map(m => m.userId);
802
+ const rawUsers = await db.findMany<FortressUser & { passwordHash?: string }>({
803
+ model: 'user',
804
+ where: [{ field: 'id', operator: 'in', value: userIds }],
805
+ });
806
+ return rawUsers.map(({ passwordHash: _, ...u }) => u);
807
+ },
808
+
809
+ async listPermissions(options?: { resource?: string }): Promise<Permission[]> {
810
+ const where = options?.resource
811
+ ? [{ field: 'resource' as const, operator: '=' as const, value: options.resource }]
812
+ : undefined;
813
+
814
+ return db.findMany<Permission>({ model: 'permission', where });
815
+ },
816
+
817
+ async createPermission(permission: PermissionInput): Promise<Permission> {
818
+ await adapter.ensureResource(permission.resource);
819
+ const result = await adapter.findOrCreatePermissionWithStatus(permission);
820
+ if (result.created) {
821
+ emit({
822
+ eventType: 'PERMISSION_CREATED',
823
+ targetId: result.permission.id,
824
+ targetType: 'permission',
825
+ metadata: {
826
+ resource: result.permission.resource,
827
+ action: result.permission.action,
828
+ effect: result.permission.effect,
829
+ conditions: result.permission.conditions,
830
+ },
831
+ });
832
+ }
833
+ return result.permission;
834
+ },
835
+
836
+ async deletePermission(permissionId: string): Promise<void> {
837
+ const existing = await withMutationLock(`permission:${permissionId}`, async (tx) => {
838
+ const permission = await tx.findOne<Permission>({
839
+ model: 'permission',
840
+ where: [{ field: 'id', operator: '=', value: permissionId }],
841
+ });
842
+ if (!permission)
843
+ throw Errors.notFound('Permission not found');
844
+ await tx.delete({ model: 'permission', where: [{ field: 'id', operator: '=', value: permissionId }] });
845
+ return permission;
846
+ });
847
+ cache?.invalidateAll();
848
+ emit({
849
+ eventType: 'PERMISSION_DELETED',
850
+ targetId: permissionId,
851
+ targetType: 'permission',
852
+ metadata: {
853
+ resource: existing.resource,
854
+ action: existing.action,
855
+ effect: existing.effect,
856
+ conditions: existing.conditions,
857
+ },
858
+ });
859
+ },
860
+
861
+ async addPermissionToRole(roleId: string, permission: PermissionInput): Promise<void> {
862
+ const role = await db.findOne<Role>({
863
+ model: 'role',
864
+ where: [{ field: 'id', operator: '=', value: roleId }],
865
+ });
866
+ if (!role) {
867
+ throw Errors.notFound('Role not found');
868
+ }
869
+
870
+ await adapter.ensureResource(permission.resource);
871
+ const perm = await adapter.findOrCreatePermission(permission);
872
+
873
+ // Check if already linked
874
+ const existing = await db.findOne<{ roleId: string }>({
875
+ model: 'role_permission',
876
+ where: [
877
+ { field: 'roleId', operator: '=', value: roleId },
878
+ { field: 'permissionId', operator: '=', value: perm.id },
879
+ ],
880
+ });
881
+ if (!existing) {
882
+ await db.create({ model: 'role_permission', data: { roleId, permissionId: perm.id } });
883
+ }
884
+
885
+ cache?.invalidateAll();
886
+ emit({ eventType: 'ROLE_PERMISSION_ADDED', targetId: roleId, targetType: 'role', metadata: { permissionId: perm.id, resource: permission.resource, action: permission.action } });
887
+ },
888
+
889
+ async removePermissionFromRole(roleId: string, permission: PermissionInput): Promise<void> {
890
+ // Resolve the permission row (must already exist; if not, nothing to
891
+ // unlink). Uses the same uniqueness key as `addPermissionToRole`
892
+ // (resource+action+effect+conditions) so the lookup matches the row
893
+ // that was previously linked.
894
+ const perm = await adapter.findPermission(permission);
895
+ if (!perm)
896
+ return;
897
+ const existingLink = await db.findOne<{ id: string }>({
898
+ model: 'role_permission',
899
+ where: [
900
+ { field: 'roleId', operator: '=', value: roleId },
901
+ { field: 'permissionId', operator: '=', value: perm.id },
902
+ ],
903
+ });
904
+ if (!existingLink)
905
+ return;
906
+ await db.delete({
907
+ model: 'role_permission',
908
+ where: [
909
+ { field: 'roleId', operator: '=', value: roleId },
910
+ { field: 'permissionId', operator: '=', value: perm.id },
911
+ ],
912
+ });
913
+ cache?.invalidateAll();
914
+ emit({
915
+ eventType: 'ROLE_PERMISSION_REMOVED',
916
+ targetId: roleId,
917
+ targetType: 'role',
918
+ metadata: { permissionId: perm.id, resource: permission.resource, action: permission.action },
919
+ });
920
+ },
921
+
922
+ // ── Service Accounts ─────────────────────────────────────────
923
+
924
+ async createServiceAccount(input: CreateServiceAccountInput): Promise<ServiceAccount> {
925
+ if (!input.name || typeof input.name !== 'string') {
926
+ throw Errors.badRequest('Service account name is required');
927
+ }
928
+ const existing = await db.findOne<ServiceAccount>({
929
+ model: 'service_account',
930
+ where: [{ field: 'name', operator: '=', value: input.name }],
931
+ });
932
+ if (existing) {
933
+ throw Errors.badRequest(`Service account with name '${input.name}' already exists`);
934
+ }
935
+ const created = await db.create<ServiceAccount>({
936
+ model: 'service_account',
937
+ data: {
938
+ name: input.name,
939
+ displayName: input.displayName ?? null,
940
+ description: input.description ?? null,
941
+ isActive: true,
942
+ },
943
+ });
944
+ emit({
945
+ eventType: 'SERVICE_ACCOUNT_CREATED',
946
+ targetId: created.id,
947
+ targetType: 'service_account',
948
+ metadata: { name: created.name },
949
+ });
950
+ return created;
951
+ },
952
+
953
+ async getServiceAccount(id: string): Promise<ServiceAccount> {
954
+ const sa = await db.findOne<ServiceAccount>({
955
+ model: 'service_account',
956
+ where: [{ field: 'id', operator: '=', value: id }],
957
+ });
958
+ if (!sa) {
959
+ throw Errors.notFound('Service account not found');
960
+ }
961
+ return sa;
962
+ },
963
+
964
+ async listServiceAccounts(
965
+ options?: { limit?: number; offset?: number },
966
+ ): Promise<{ serviceAccounts: ServiceAccount[]; total: number }> {
967
+ const [serviceAccounts, total] = await Promise.all([
968
+ db.findMany<ServiceAccount>({
969
+ model: 'service_account',
970
+ limit: options?.limit ?? 50,
971
+ offset: options?.offset ?? 0,
972
+ sortBy: { field: 'id', direction: 'asc' },
973
+ }),
974
+ db.count({ model: 'service_account' }),
975
+ ]);
976
+ return { serviceAccounts, total };
977
+ },
978
+
979
+ async updateServiceAccount(
980
+ id: string,
981
+ data: { displayName?: string | null; description?: string | null; isActive?: boolean },
982
+ ): Promise<ServiceAccount> {
983
+ const existing = await db.findOne<ServiceAccount>({
984
+ model: 'service_account',
985
+ where: [{ field: 'id', operator: '=', value: id }],
986
+ });
987
+ if (!existing) {
988
+ throw Errors.notFound('Service account not found');
989
+ }
990
+ // `name` is immutable after creation — matches Kubernetes / IAM conventions.
991
+ const updateData: Record<string, unknown> = { updatedAt: new Date() };
992
+ if (data.displayName !== undefined)
993
+ updateData.displayName = data.displayName;
994
+ if (data.description !== undefined)
995
+ updateData.description = data.description;
996
+ if (data.isActive !== undefined)
997
+ updateData.isActive = data.isActive;
998
+
999
+ const updated = await db.update<ServiceAccount>({
1000
+ model: 'service_account',
1001
+ where: [{ field: 'id', operator: '=', value: id }],
1002
+ data: updateData,
1003
+ });
1004
+
1005
+ // Flipping isActive affects permission resolution — invalidate the cache.
1006
+ cache?.invalidate(subjectCacheKey({ type: 'SERVICE_ACCOUNT', id }));
1007
+ emit({
1008
+ eventType: 'SERVICE_ACCOUNT_UPDATED',
1009
+ targetId: id,
1010
+ targetType: 'service_account',
1011
+ metadata: data as Record<string, unknown>,
1012
+ });
1013
+ return updated!;
1014
+ },
1015
+
1016
+ async deleteServiceAccount(id: string): Promise<void> {
1017
+ const existing = await db.findOne<ServiceAccount>({
1018
+ model: 'service_account',
1019
+ where: [{ field: 'id', operator: '=', value: id }],
1020
+ });
1021
+ if (!existing) {
1022
+ throw Errors.notFound('Service account not found');
1023
+ }
1024
+
1025
+ // Cascade: remove bindings first, then the account itself.
1026
+ await db.delete({
1027
+ model: 'role_binding',
1028
+ where: [
1029
+ { field: 'subjectType', operator: '=', value: 'SERVICE_ACCOUNT' },
1030
+ { field: 'subjectId', operator: '=', value: id },
1031
+ ],
1032
+ });
1033
+ await db.delete({
1034
+ model: 'direct_permission_binding',
1035
+ where: [
1036
+ { field: 'subjectType', operator: '=', value: 'SERVICE_ACCOUNT' },
1037
+ { field: 'subjectId', operator: '=', value: id },
1038
+ ],
1039
+ });
1040
+ await db.delete({
1041
+ model: 'service_account',
1042
+ where: [{ field: 'id', operator: '=', value: id }],
1043
+ });
1044
+
1045
+ cache?.invalidate(subjectCacheKey({ type: 'SERVICE_ACCOUNT', id }));
1046
+ // Observers (api-key plugin, audit log, …) react to this. The api-key
1047
+ // plugin uses it to hard-delete keys owned by the deleted account.
1048
+ emit({
1049
+ eventType: 'SERVICE_ACCOUNT_DELETED',
1050
+ targetId: id,
1051
+ targetType: 'service_account',
1052
+ metadata: { name: existing.name },
1053
+ });
1054
+ },
1055
+
1056
+ async bindRoleToServiceAccount(
1057
+ serviceAccountId: string,
1058
+ roleId: string,
1059
+ tenantId?: string,
1060
+ ): Promise<void> {
1061
+ const inserted = await createRoleBindingIfMissing(db, 'SERVICE_ACCOUNT', serviceAccountId, roleId, tenantId);
1062
+ if (!inserted)
1063
+ return;
1064
+ cache?.invalidate(subjectCacheKey({ type: 'SERVICE_ACCOUNT', id: serviceAccountId }));
1065
+ emit({
1066
+ eventType: 'ROLE_BOUND',
1067
+ targetId: roleId,
1068
+ targetType: 'role',
1069
+ metadata: { subjectType: 'SERVICE_ACCOUNT', subjectId: serviceAccountId, tenantId },
1070
+ });
1071
+ },
1072
+
1073
+ async unbindRoleFromServiceAccount(
1074
+ serviceAccountId: string,
1075
+ roleId: string,
1076
+ tenantId?: string | null,
1077
+ ): Promise<void> {
1078
+ const where: WhereClause[] = [
1079
+ { field: 'roleId', operator: '=', value: roleId },
1080
+ { field: 'subjectType', operator: '=', value: 'SERVICE_ACCOUNT' },
1081
+ { field: 'subjectId', operator: '=', value: serviceAccountId },
1082
+ ];
1083
+ if (tenantId === null)
1084
+ where.push({ field: 'tenantId', operator: 'isNull', value: null });
1085
+ else if (tenantId !== undefined)
1086
+ where.push({ field: 'tenantId', operator: '=', value: tenantId });
1087
+ await db.delete({ model: 'role_binding', where });
1088
+ cache?.invalidate(subjectCacheKey({ type: 'SERVICE_ACCOUNT', id: serviceAccountId }));
1089
+ emit({
1090
+ eventType: 'ROLE_UNBOUND',
1091
+ targetId: roleId,
1092
+ targetType: 'role',
1093
+ metadata: { subjectType: 'SERVICE_ACCOUNT', subjectId: serviceAccountId, tenantId },
1094
+ });
1095
+ },
1096
+
1097
+ async bindPermissionToServiceAccount(
1098
+ serviceAccountId: string,
1099
+ permission: PermissionInput,
1100
+ tenantId?: string,
1101
+ ): Promise<void> {
1102
+ await adapter.ensureResource(permission.resource);
1103
+ const perm = await adapter.findOrCreatePermission(permission);
1104
+ const inserted = await createDirectPermissionBindingIfMissing(db, 'SERVICE_ACCOUNT', serviceAccountId, perm.id, tenantId);
1105
+ if (!inserted)
1106
+ return;
1107
+ cache?.invalidate(subjectCacheKey({ type: 'SERVICE_ACCOUNT', id: serviceAccountId }));
1108
+ emit({
1109
+ eventType: 'PERMISSION_CHANGED',
1110
+ targetId: perm.id,
1111
+ targetType: 'permission',
1112
+ metadata: { action: 'bind', subjectType: 'SERVICE_ACCOUNT', serviceAccountId, tenantId },
1113
+ });
1114
+ },
1115
+
1116
+ async unbindPermissionFromServiceAccount(
1117
+ serviceAccountId: string,
1118
+ permissionId: string,
1119
+ tenantId?: string | null,
1120
+ ): Promise<void> {
1121
+ const where = [
1122
+ { field: 'permissionId' as const, operator: '=' as const, value: permissionId },
1123
+ { field: 'subjectType' as const, operator: '=' as const, value: 'SERVICE_ACCOUNT' },
1124
+ { field: 'subjectId' as const, operator: '=' as const, value: serviceAccountId },
1125
+ ...unbindTenantWhere(tenantId),
1126
+ ];
1127
+ await db.delete({ model: 'direct_permission_binding', where });
1128
+ cache?.invalidate(subjectCacheKey({ type: 'SERVICE_ACCOUNT', id: serviceAccountId }));
1129
+ emit({
1130
+ eventType: 'PERMISSION_CHANGED',
1131
+ targetId: permissionId,
1132
+ targetType: 'permission',
1133
+ metadata: { action: 'unbind', subjectType: 'SERVICE_ACCOUNT', serviceAccountId, tenantId },
1134
+ });
1135
+ },
1136
+ };
1137
+ }