@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.
- package/CHANGELOG.md +1011 -0
- package/LICENSE +21 -0
- package/README.md +760 -0
- package/SECURITY.md +197 -0
- package/bin/fortress.ts +667 -0
- package/dist/auth-service-BcyQ2PuZ.d.cts +307 -0
- package/dist/auth-service-BkzZkWfH.d.ts +307 -0
- package/dist/config-B2366s_G.d.ts +665 -0
- package/dist/config-GtlVt6ZD.d.cts +665 -0
- package/dist/convert-routes-BLCQT57f.d.ts +72 -0
- package/dist/convert-routes-BdMvP2_X.d.cts +72 -0
- package/dist/crypto.cjs +61 -0
- package/dist/crypto.d.cts +36 -0
- package/dist/crypto.d.ts +36 -0
- package/dist/crypto.js +35 -0
- package/dist/drift-BT1wtMDJ.d.cts +22 -0
- package/dist/drift-k9LiYpRP.d.ts +22 -0
- package/dist/drizzle/pg.cjs +481 -0
- package/dist/drizzle/pg.d.cts +15 -0
- package/dist/drizzle/pg.d.ts +15 -0
- package/dist/drizzle/pg.js +454 -0
- package/dist/drizzle.cjs +1442 -0
- package/dist/drizzle.d.cts +85 -0
- package/dist/drizzle.d.ts +85 -0
- package/dist/drizzle.js +1411 -0
- package/dist/express.cjs +1357 -0
- package/dist/express.d.cts +333 -0
- package/dist/express.d.ts +333 -0
- package/dist/express.js +1314 -0
- package/dist/fetcher.cjs +77 -0
- package/dist/fetcher.d.cts +7 -0
- package/dist/fetcher.d.ts +7 -0
- package/dist/fetcher.js +41 -0
- package/dist/fortress-BmSvFfqK.d.cts +1089 -0
- package/dist/fortress-uA-_cheR.d.ts +1089 -0
- package/dist/hono.cjs +1530 -0
- package/dist/hono.d.cts +514 -0
- package/dist/hono.d.ts +514 -0
- package/dist/hono.js +1483 -0
- package/dist/index-CxEkfCA0.d.cts +77 -0
- package/dist/index-CxEkfCA0.d.ts +77 -0
- package/dist/index-D054pcb-.d.cts +146 -0
- package/dist/index-jAMbbUAY.d.ts +146 -0
- package/dist/index.cjs +9046 -0
- package/dist/index.d.cts +717 -0
- package/dist/index.d.ts +717 -0
- package/dist/index.js +8935 -0
- package/dist/jwt.cjs +255 -0
- package/dist/jwt.d.cts +90 -0
- package/dist/jwt.d.ts +90 -0
- package/dist/jwt.js +227 -0
- package/dist/otel.cjs +108 -0
- package/dist/otel.d.cts +62 -0
- package/dist/otel.d.ts +62 -0
- package/dist/otel.js +73 -0
- package/dist/plugins/account-lockout.cjs +322 -0
- package/dist/plugins/account-lockout.d.cts +42 -0
- package/dist/plugins/account-lockout.d.ts +42 -0
- package/dist/plugins/account-lockout.js +295 -0
- package/dist/plugins/admin.cjs +2378 -0
- package/dist/plugins/admin.d.cts +72 -0
- package/dist/plugins/admin.d.ts +72 -0
- package/dist/plugins/admin.js +2351 -0
- package/dist/plugins/api-key.cjs +792 -0
- package/dist/plugins/api-key.d.cts +121 -0
- package/dist/plugins/api-key.d.ts +121 -0
- package/dist/plugins/api-key.js +765 -0
- package/dist/plugins/audit-log.cjs +493 -0
- package/dist/plugins/audit-log.d.cts +86 -0
- package/dist/plugins/audit-log.d.ts +86 -0
- package/dist/plugins/audit-log.js +468 -0
- package/dist/plugins/data-isolation.cjs +211 -0
- package/dist/plugins/data-isolation.d.cts +49 -0
- package/dist/plugins/data-isolation.d.ts +49 -0
- package/dist/plugins/data-isolation.js +186 -0
- package/dist/plugins/email-verification.cjs +273 -0
- package/dist/plugins/email-verification.d.cts +44 -0
- package/dist/plugins/email-verification.d.ts +44 -0
- package/dist/plugins/email-verification.js +246 -0
- package/dist/plugins/magic-link.cjs +231 -0
- package/dist/plugins/magic-link.d.cts +39 -0
- package/dist/plugins/magic-link.d.ts +39 -0
- package/dist/plugins/magic-link.js +204 -0
- package/dist/plugins/oauth.cjs +1696 -0
- package/dist/plugins/oauth.d.cts +406 -0
- package/dist/plugins/oauth.d.ts +406 -0
- package/dist/plugins/oauth.js +1669 -0
- package/dist/plugins/openapi.cjs +1185 -0
- package/dist/plugins/openapi.d.cts +6 -0
- package/dist/plugins/openapi.d.ts +6 -0
- package/dist/plugins/openapi.js +1158 -0
- package/dist/plugins/rate-limit/express.cjs +55 -0
- package/dist/plugins/rate-limit/express.d.cts +64 -0
- package/dist/plugins/rate-limit/express.d.ts +64 -0
- package/dist/plugins/rate-limit/express.js +30 -0
- package/dist/plugins/rate-limit/hono.cjs +51 -0
- package/dist/plugins/rate-limit/hono.d.cts +59 -0
- package/dist/plugins/rate-limit/hono.d.ts +59 -0
- package/dist/plugins/rate-limit/hono.js +26 -0
- package/dist/plugins/rate-limit/sveltekit.cjs +49 -0
- package/dist/plugins/rate-limit/sveltekit.d.cts +59 -0
- package/dist/plugins/rate-limit/sveltekit.d.ts +59 -0
- package/dist/plugins/rate-limit/sveltekit.js +24 -0
- package/dist/plugins/rate-limit.cjs +354 -0
- package/dist/plugins/rate-limit.d.cts +140 -0
- package/dist/plugins/rate-limit.d.ts +140 -0
- package/dist/plugins/rate-limit.js +325 -0
- package/dist/plugins/social-login.cjs +905 -0
- package/dist/plugins/social-login.d.cts +114 -0
- package/dist/plugins/social-login.d.ts +114 -0
- package/dist/plugins/social-login.js +880 -0
- package/dist/plugins/tenancy.cjs +780 -0
- package/dist/plugins/tenancy.d.cts +108 -0
- package/dist/plugins/tenancy.d.ts +108 -0
- package/dist/plugins/tenancy.js +753 -0
- package/dist/plugins/two-factor.cjs +555 -0
- package/dist/plugins/two-factor.d.cts +67 -0
- package/dist/plugins/two-factor.d.ts +67 -0
- package/dist/plugins/two-factor.js +526 -0
- package/dist/plugins/webauthn.cjs +786 -0
- package/dist/plugins/webauthn.d.cts +72 -0
- package/dist/plugins/webauthn.d.ts +72 -0
- package/dist/plugins/webauthn.js +766 -0
- package/dist/plugins/webhook.cjs +819 -0
- package/dist/plugins/webhook.d.cts +254 -0
- package/dist/plugins/webhook.d.ts +254 -0
- package/dist/plugins/webhook.js +789 -0
- package/dist/protect-D1v_0upK.d.cts +123 -0
- package/dist/protect-DsxV_-dJ.d.ts +123 -0
- package/dist/sveltekit.cjs +1258 -0
- package/dist/sveltekit.d.cts +420 -0
- package/dist/sveltekit.d.ts +420 -0
- package/dist/sveltekit.js +1207 -0
- package/dist/testing.cjs +4294 -0
- package/dist/testing.d.cts +197 -0
- package/dist/testing.d.ts +197 -0
- package/dist/testing.js +4264 -0
- package/dist/types-et0R8tsC.d.cts +217 -0
- package/dist/types-et0R8tsC.d.ts +217 -0
- package/docs/adapter-typed-helpers.md +192 -0
- package/docs/admin-recipes.md +227 -0
- package/docs/architecture.md +434 -0
- package/docs/ci/github-actions.yml +59 -0
- package/docs/ci.md +109 -0
- package/docs/compatibility.md +115 -0
- package/docs/deployment.md +305 -0
- package/docs/hardening.md +118 -0
- package/docs/host-owned-routes.md +154 -0
- package/docs/migrations/0001-schema-version.md +54 -0
- package/docs/migrations/0002-initial-schema.md +84 -0
- package/docs/migrations/upgrade-guide.md +160 -0
- package/docs/observability.md +394 -0
- package/docs/plugins/account-lockout.md +131 -0
- package/docs/plugins/admin.md +402 -0
- package/docs/plugins/api-key.md +327 -0
- package/docs/plugins/audit-log.md +261 -0
- package/docs/plugins/data-isolation.md +186 -0
- package/docs/plugins/email-verification.md +121 -0
- package/docs/plugins/magic-link.md +123 -0
- package/docs/plugins/oauth.md +544 -0
- package/docs/plugins/openapi.md +250 -0
- package/docs/plugins/rate-limit.md +223 -0
- package/docs/plugins/social-login.md +337 -0
- package/docs/plugins/tenancy.md +122 -0
- package/docs/plugins/two-factor.md +191 -0
- package/docs/plugins/webauthn.md +188 -0
- package/docs/plugins/webhook.md +242 -0
- package/docs/policy-as-code.md +156 -0
- package/docs/route-manifest.md +46 -0
- package/docs/security.md +261 -0
- package/docs/threat-model.md +161 -0
- package/examples/README.md +119 -0
- package/examples/express-app/index.ts +747 -0
- package/examples/hono-app/docker-compose.yml +14 -0
- package/examples/hono-app/index.ts +1009 -0
- package/examples/policy/fortress.policy.json +47 -0
- package/examples/sveltekit-app/README.md +125 -0
- package/examples/sveltekit-app/src/app.d.ts +16 -0
- package/examples/sveltekit-app/src/hooks.server.ts +23 -0
- package/examples/sveltekit-app/src/lib/server/fortress.ts +81 -0
- package/examples/sveltekit-app/src/routes/api/echo/[id]/+server.ts +32 -0
- package/examples/sveltekit-app/src/routes/api/fortress/[...path]/+server.ts +15 -0
- package/examples/sveltekit-app/src/routes/dashboard/+page.server.ts +20 -0
- package/examples/sveltekit-app/src/routes/login/+page.server.ts +48 -0
- package/examples/sveltekit-app/src/routes/oauth/consent/+page.server.ts +69 -0
- package/examples/sveltekit-app/src/routes/oauth/consent/+page.svelte +83 -0
- package/migrations/pg/0001_schema_version.down.sql +2 -0
- package/migrations/pg/0001_schema_version.sql +8 -0
- package/migrations/pg/0002_initial_schema.down.sql +36 -0
- package/migrations/pg/0002_initial_schema.sql +376 -0
- package/migrations/pg/0003_auth_continuation.down.sql +6 -0
- package/migrations/pg/0003_auth_continuation.sql +30 -0
- package/migrations/pg/0004_tenant_default_unique.down.sql +1 -0
- package/migrations/pg/0004_tenant_default_unique.sql +14 -0
- package/migrations/pg/0005_hot_indexes_timestamptz.down.sql +71 -0
- package/migrations/pg/0005_hot_indexes_timestamptz.sql +71 -0
- package/migrations/pg/0006_canonical_email.down.sql +3 -0
- package/migrations/pg/0006_canonical_email.sql +8 -0
- package/migrations/pg/0007_audit_chain_anchor.down.sql +1 -0
- package/migrations/pg/0007_audit_chain_anchor.sql +8 -0
- package/migrations/pg/0008_two_factor_hardening.down.sql +8 -0
- package/migrations/pg/0008_two_factor_hardening.sql +8 -0
- package/migrations/pg/0009_encrypt_totp_secrets.down.sql +2 -0
- package/migrations/pg/0009_encrypt_totp_secrets.sql +5 -0
- package/migrations/pg/0010_bigint_append_only_ids.down.sql +2 -0
- package/migrations/pg/0010_bigint_append_only_ids.sql +23 -0
- package/migrations/sqlite/0001_schema_version.down.sql +2 -0
- package/migrations/sqlite/0001_schema_version.sql +8 -0
- package/migrations/sqlite/0002_initial_schema.down.sql +36 -0
- package/migrations/sqlite/0002_initial_schema.sql +376 -0
- package/migrations/sqlite/0003_auth_continuation.down.sql +27 -0
- package/migrations/sqlite/0003_auth_continuation.sql +45 -0
- package/migrations/sqlite/0004_tenant_default_unique.down.sql +1 -0
- package/migrations/sqlite/0004_tenant_default_unique.sql +13 -0
- package/migrations/sqlite/0005_hot_indexes_timestamptz.down.sql +10 -0
- package/migrations/sqlite/0005_hot_indexes_timestamptz.sql +10 -0
- package/migrations/sqlite/0006_canonical_email.down.sql +3 -0
- package/migrations/sqlite/0006_canonical_email.sql +8 -0
- package/migrations/sqlite/0007_audit_chain_anchor.down.sql +1 -0
- package/migrations/sqlite/0007_audit_chain_anchor.sql +8 -0
- package/migrations/sqlite/0008_two_factor_hardening.down.sql +14 -0
- package/migrations/sqlite/0008_two_factor_hardening.sql +7 -0
- package/migrations/sqlite/0009_encrypt_totp_secrets.down.sql +2 -0
- package/migrations/sqlite/0009_encrypt_totp_secrets.sql +5 -0
- package/migrations/sqlite/0010_bigint_append_only_ids.down.sql +1 -0
- package/migrations/sqlite/0010_bigint_append_only_ids.sql +2 -0
- package/package.json +398 -0
- package/src/adapters/database/index.ts +63 -0
- package/src/adapters/database/types.ts +22 -0
- package/src/changelog-script.test.ts +21 -0
- package/src/cli.test.ts +79 -0
- package/src/core/auth/auth-admin.test.ts +247 -0
- package/src/core/auth/auth-endpoints.ts +558 -0
- package/src/core/auth/auth-observer.test.ts +191 -0
- package/src/core/auth/auth-service.ts +1464 -0
- package/src/core/auth/email.test.ts +17 -0
- package/src/core/auth/email.ts +11 -0
- package/src/core/auth/hooks.test.ts +251 -0
- package/src/core/auth/impersonation.test.ts +179 -0
- package/src/core/auth/jwt.test.ts +184 -0
- package/src/core/auth/jwt.ts +239 -0
- package/src/core/auth/login-timing.test.ts +77 -0
- package/src/core/auth/password-policy.test.ts +253 -0
- package/src/core/auth/password-policy.ts +220 -0
- package/src/core/auth/password.test.ts +42 -0
- package/src/core/auth/password.ts +62 -0
- package/src/core/auth/post-auth-gate.test.ts +258 -0
- package/src/core/auth/post-auth-gate.ts +228 -0
- package/src/core/auth/refresh-token.test.ts +86 -0
- package/src/core/auth/refresh-token.ts +105 -0
- package/src/core/auth/timing-safe.ts +23 -0
- package/src/core/config.ts +212 -0
- package/src/core/endpoint.ts +149 -0
- package/src/core/errors.ts +199 -0
- package/src/core/fetcher-interop.test.ts +106 -0
- package/src/core/fortress.test.ts +354 -0
- package/src/core/fortress.ts +550 -0
- package/src/core/http/call.test.ts +148 -0
- package/src/core/http/call.ts +149 -0
- package/src/core/http/cookie-serialize.test.ts +198 -0
- package/src/core/http/cookie-serialize.ts +107 -0
- package/src/core/http/csrf.test.ts +140 -0
- package/src/core/http/csrf.ts +173 -0
- package/src/core/http/dispatch.ts +652 -0
- package/src/core/http/error-response.test.ts +69 -0
- package/src/core/http/error-response.ts +93 -0
- package/src/core/http/fortress-rbac.test.ts +43 -0
- package/src/core/http/fortress-rbac.ts +127 -0
- package/src/core/http/handle-request.test.ts +441 -0
- package/src/core/http/handle-request.ts +354 -0
- package/src/core/http/match.test.ts +122 -0
- package/src/core/http/match.ts +126 -0
- package/src/core/http/outbound.test.ts +34 -0
- package/src/core/http/outbound.ts +105 -0
- package/src/core/http/plugin-middleware.ts +67 -0
- package/src/core/http/principal.test.ts +345 -0
- package/src/core/http/principal.ts +86 -0
- package/src/core/http/protect.test.ts +220 -0
- package/src/core/http/protect.ts +394 -0
- package/src/core/http/token-extraction.test.ts +59 -0
- package/src/core/http/token-extraction.ts +53 -0
- package/src/core/iam/explain.test.ts +177 -0
- package/src/core/iam/explain.ts +302 -0
- package/src/core/iam/iam-endpoints.ts +779 -0
- package/src/core/iam/iam-service.test.ts +829 -0
- package/src/core/iam/iam-service.ts +1137 -0
- package/src/core/iam/permission-cache.test.ts +115 -0
- package/src/core/iam/permission-cache.ts +71 -0
- package/src/core/iam/permission-check-observer.test.ts +90 -0
- package/src/core/iam/permission-evaluator.test.ts +291 -0
- package/src/core/iam/permission-evaluator.ts +198 -0
- package/src/core/iam/permission-sync.test.ts +166 -0
- package/src/core/iam/permission-sync.ts +192 -0
- package/src/core/iam/resource-sync.test.ts +70 -0
- package/src/core/iam/resource-sync.ts +182 -0
- package/src/core/iam/service-account-http.test.ts +529 -0
- package/src/core/iam/service-account.test.ts +282 -0
- package/src/core/internal-adapter.test.ts +389 -0
- package/src/core/internal-adapter.ts +435 -0
- package/src/core/json-schema.ts +50 -0
- package/src/core/manifest/__snapshots__/route-manifest.test.ts.snap +192 -0
- package/src/core/manifest/drift.ts +103 -0
- package/src/core/manifest/route-manifest.test.ts +142 -0
- package/src/core/manifest/route-manifest.ts +154 -0
- package/src/core/migrate.test.ts +72 -0
- package/src/core/migrations/engine.test.ts +308 -0
- package/src/core/migrations/engine.ts +548 -0
- package/src/core/migrations/migrations.ts +1771 -0
- package/src/core/migrations/pg-migration.integration-test.ts +353 -0
- package/src/core/migrations/upgrade-fixture.test.ts +378 -0
- package/src/core/observability/db-instrumentation.ts +205 -0
- package/src/core/observability/listener-list.test.ts +124 -0
- package/src/core/observability/listener-list.ts +73 -0
- package/src/core/observability/logger.test.ts +43 -0
- package/src/core/observability/logger.ts +49 -0
- package/src/core/observability/spans.test.ts +193 -0
- package/src/core/observability/types.ts +80 -0
- package/src/core/openapi-drift.test.ts +41 -0
- package/src/core/openapi.ts +47 -0
- package/src/core/plugin-methods-map.ts +75 -0
- package/src/core/plugin-runner.test.ts +233 -0
- package/src/core/plugin-runner.ts +276 -0
- package/src/core/plugin.ts +210 -0
- package/src/core/policy/apply.ts +272 -0
- package/src/core/policy/diff.ts +288 -0
- package/src/core/policy/loader.test.ts +63 -0
- package/src/core/policy/loader.ts +214 -0
- package/src/core/policy/policy.test.ts +232 -0
- package/src/core/policy/types.ts +105 -0
- package/src/core/schema-builder.test.ts +660 -0
- package/src/core/schema-builder.ts +881 -0
- package/src/core/standard-schema.ts +56 -0
- package/src/core/types.ts +258 -0
- package/src/core/validation.test.ts +195 -0
- package/src/core/validation.ts +192 -0
- package/src/drizzle/adapter.test.ts +425 -0
- package/src/drizzle/adapter.ts +474 -0
- package/src/drizzle/index.ts +31 -0
- package/src/drizzle/pg/adapter.integration-test.ts +456 -0
- package/src/drizzle/pg/index.ts +13 -0
- package/src/drizzle/pg/pg.integration-test.ts +1539 -0
- package/src/drizzle/pg/schema.ts +539 -0
- package/src/drizzle/pg-error-map.test.ts +218 -0
- package/src/drizzle/pg-error-map.ts +151 -0
- package/src/drizzle/schema.ts +544 -0
- package/src/drizzle/sqlite-serialization.test.ts +106 -0
- package/src/express/express-api-key-user-routes.test.ts +241 -0
- package/src/express/express.test.ts +442 -0
- package/src/express/handle.ts +191 -0
- package/src/express/index.ts +62 -0
- package/src/express/middleware.ts +551 -0
- package/src/express/protect.ts +154 -0
- package/src/express/validated.test.ts +86 -0
- package/src/express/validated.ts +99 -0
- package/src/fetcher/index.ts +81 -0
- package/src/hono/convert-routes.test.ts +220 -0
- package/src/hono/convert-routes.ts +196 -0
- package/src/hono/converters.test.ts +50 -0
- package/src/hono/converters.ts +43 -0
- package/src/hono/handle.ts +79 -0
- package/src/hono/helpers.ts +2 -0
- package/src/hono/hono-api-key-user-routes.test.ts +225 -0
- package/src/hono/hono-handlers.test.ts +861 -0
- package/src/hono/hono.test.ts +454 -0
- package/src/hono/index.ts +101 -0
- package/src/hono/middleware/auth.ts +236 -0
- package/src/hono/middleware/auth.types.test.ts +94 -0
- package/src/hono/middleware/csrf.test.ts +127 -0
- package/src/hono/middleware/csrf.ts +68 -0
- package/src/hono/middleware/error-handler.ts +12 -0
- package/src/hono/middleware/plugin-middleware.ts +35 -0
- package/src/hono/middleware/rbac.ts +131 -0
- package/src/hono/middleware/security-headers.test.ts +88 -0
- package/src/hono/middleware/security-headers.ts +68 -0
- package/src/hono/openapi.ts +237 -0
- package/src/hono/protect.ts +87 -0
- package/src/hono/validated.test.ts +123 -0
- package/src/hono/validated.ts +62 -0
- package/src/index.ts +309 -0
- package/src/integration.test.ts +718 -0
- package/src/internal/changelog.ts +56 -0
- package/src/otel/index.ts +158 -0
- package/src/otel/otel-types.test.ts +35 -0
- package/src/otel/otel.test.ts +235 -0
- package/src/plugins/account-lockout/account-lockout.test.ts +367 -0
- package/src/plugins/account-lockout/index.ts +267 -0
- package/src/plugins/admin/admin-api-key.test.ts +438 -0
- package/src/plugins/admin/index.ts +1612 -0
- package/src/plugins/api-key/api-key.test.ts +684 -0
- package/src/plugins/api-key/core.ts +336 -0
- package/src/plugins/api-key/index.ts +315 -0
- package/src/plugins/audit-log/audit-log.test.ts +749 -0
- package/src/plugins/audit-log/index.ts +680 -0
- package/src/plugins/data-isolation/data-isolation.test.ts +197 -0
- package/src/plugins/data-isolation/index.ts +157 -0
- package/src/plugins/email-verification/email-verification.test.ts +199 -0
- package/src/plugins/email-verification/index.ts +190 -0
- package/src/plugins/magic-link/index.ts +129 -0
- package/src/plugins/magic-link/magic-link.test.ts +156 -0
- package/src/plugins/oauth/id-token.ts +86 -0
- package/src/plugins/oauth/index.ts +2145 -0
- package/src/plugins/oauth/jwks.test.ts +32 -0
- package/src/plugins/oauth/jwks.ts +182 -0
- package/src/plugins/oauth/oauth.test.ts +2220 -0
- package/src/plugins/oauth/pkce.ts +58 -0
- package/src/plugins/openapi/index.ts +298 -0
- package/src/plugins/openapi/openapi.test.ts +425 -0
- package/src/plugins/openapi/spec-builder.ts +287 -0
- package/src/plugins/rate-limit/express.test.ts +89 -0
- package/src/plugins/rate-limit/express.ts +86 -0
- package/src/plugins/rate-limit/hono.test.ts +60 -0
- package/src/plugins/rate-limit/hono.ts +74 -0
- package/src/plugins/rate-limit/index.ts +383 -0
- package/src/plugins/rate-limit/memory-store.ts +61 -0
- package/src/plugins/rate-limit/rate-limit.test.ts +756 -0
- package/src/plugins/rate-limit/sveltekit.test.ts +58 -0
- package/src/plugins/rate-limit/sveltekit.ts +66 -0
- package/src/plugins/social-login/index.ts +715 -0
- package/src/plugins/social-login/providers/apple.ts +34 -0
- package/src/plugins/social-login/providers/discord.ts +26 -0
- package/src/plugins/social-login/providers/github.ts +54 -0
- package/src/plugins/social-login/providers/google.ts +23 -0
- package/src/plugins/social-login/providers/index.ts +22 -0
- package/src/plugins/social-login/providers/microsoft.ts +35 -0
- package/src/plugins/social-login/providers/oidc.ts +37 -0
- package/src/plugins/social-login/providers/providers.test.ts +211 -0
- package/src/plugins/social-login/social-login.test.ts +675 -0
- package/src/plugins/social-login/types.ts +80 -0
- package/src/plugins/tenancy/index.ts +544 -0
- package/src/plugins/tenancy/tenancy.test.ts +323 -0
- package/src/plugins/two-factor/index.ts +569 -0
- package/src/plugins/two-factor/two-factor.test.ts +235 -0
- package/src/plugins/webauthn/index.ts +554 -0
- package/src/plugins/webauthn/webauthn.test.ts +472 -0
- package/src/plugins/webhook/builtin-events.ts +29 -0
- package/src/plugins/webhook/delivery.test.ts +51 -0
- package/src/plugins/webhook/delivery.ts +154 -0
- package/src/plugins/webhook/hooks.ts +89 -0
- package/src/plugins/webhook/index.ts +445 -0
- package/src/plugins/webhook/queue/database.ts +67 -0
- package/src/plugins/webhook/queue/in-memory.test.ts +48 -0
- package/src/plugins/webhook/queue/in-memory.ts +71 -0
- package/src/plugins/webhook/queue/types.ts +51 -0
- package/src/plugins/webhook/registry.test.ts +48 -0
- package/src/plugins/webhook/registry.ts +64 -0
- package/src/plugins/webhook/signing.ts +45 -0
- package/src/plugins/webhook/ssrf.ts +198 -0
- package/src/plugins/webhook/types.ts +138 -0
- package/src/plugins/webhook/webhook.test.ts +324 -0
- package/src/sveltekit/actions.ts +233 -0
- package/src/sveltekit/catch-all.ts +43 -0
- package/src/sveltekit/cookies.ts +136 -0
- package/src/sveltekit/handle.ts +356 -0
- package/src/sveltekit/helpers.ts +69 -0
- package/src/sveltekit/index.ts +74 -0
- package/src/sveltekit/protect.ts +80 -0
- package/src/sveltekit/sveltekit-types.test.ts +31 -0
- package/src/sveltekit/sveltekit.test.ts +799 -0
- package/src/sveltekit/types.ts +134 -0
- package/src/sveltekit/validated.test.ts +117 -0
- package/src/sveltekit/validated.ts +78 -0
- package/src/testing/adapter-conformance.test.ts +408 -0
- package/src/testing/checks.test.ts +124 -0
- package/src/testing/checks.ts +304 -0
- 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
|
+
}
|