@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,282 @@
|
|
|
1
|
+
import type { Fortress } from '../fortress';
|
|
2
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { createTestAdapter } from '../../testing';
|
|
4
|
+
import { createFortress } from '../fortress';
|
|
5
|
+
|
|
6
|
+
const SECRET = 'service-account-test-secret-32ch';
|
|
7
|
+
|
|
8
|
+
describe('iam-service: service accounts', () => {
|
|
9
|
+
let fortress: Fortress;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
fortress = createFortress({
|
|
13
|
+
jwt: { key: SECRET },
|
|
14
|
+
database: createTestAdapter(),
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('creates a service account with all metadata', async () => {
|
|
19
|
+
const sa = await fortress.iam.createServiceAccount({
|
|
20
|
+
name: 'ci-deploy',
|
|
21
|
+
displayName: 'CI Deploy',
|
|
22
|
+
description: 'Runs production deploys from CI',
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
expect(sa.id).toBeDefined();
|
|
26
|
+
expect(sa.name).toBe('ci-deploy');
|
|
27
|
+
expect(sa.displayName).toBe('CI Deploy');
|
|
28
|
+
expect(sa.description).toBe('Runs production deploys from CI');
|
|
29
|
+
expect(sa.isActive).toBe(true);
|
|
30
|
+
expect(sa.createdAt).toBeInstanceOf(Date);
|
|
31
|
+
expect(sa.updatedAt).toBeInstanceOf(Date);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('createServiceAccount rejects duplicate names', async () => {
|
|
35
|
+
await fortress.iam.createServiceAccount({ name: 'ci-deploy' });
|
|
36
|
+
await expect(
|
|
37
|
+
fortress.iam.createServiceAccount({ name: 'ci-deploy' }),
|
|
38
|
+
).rejects.toThrow(/already exists/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('getServiceAccount throws notFound for missing id', async () => {
|
|
42
|
+
await expect(fortress.iam.getServiceAccount('9999')).rejects.toThrow(/not found/i);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('listServiceAccounts paginates and returns total', async () => {
|
|
46
|
+
for (let i = 0; i < 5; i++) {
|
|
47
|
+
await fortress.iam.createServiceAccount({ name: `sa-${i}` });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const page1 = await fortress.iam.listServiceAccounts({ limit: 3, offset: 0 });
|
|
51
|
+
expect(page1.serviceAccounts).toHaveLength(3);
|
|
52
|
+
expect(page1.total).toBe(5);
|
|
53
|
+
|
|
54
|
+
const page2 = await fortress.iam.listServiceAccounts({ limit: 3, offset: 3 });
|
|
55
|
+
expect(page2.serviceAccounts).toHaveLength(2);
|
|
56
|
+
expect(page2.total).toBe(5);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('updateServiceAccount changes displayName/description/isActive but not name', async () => {
|
|
60
|
+
const sa = await fortress.iam.createServiceAccount({ name: 'ci-deploy' });
|
|
61
|
+
const updated = await fortress.iam.updateServiceAccount(sa.id, {
|
|
62
|
+
displayName: 'New Display',
|
|
63
|
+
description: 'New desc',
|
|
64
|
+
isActive: false,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(updated.name).toBe('ci-deploy'); // immutable
|
|
68
|
+
expect(updated.displayName).toBe('New Display');
|
|
69
|
+
expect(updated.description).toBe('New desc');
|
|
70
|
+
expect(updated.isActive).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('deleteServiceAccount removes the account, role bindings, and direct bindings', async () => {
|
|
74
|
+
const sa = await fortress.iam.createServiceAccount({ name: 'to-delete' });
|
|
75
|
+
const role = await fortress.iam.createRole('rolex', [
|
|
76
|
+
{ resource: 'deploy', action: 'run' },
|
|
77
|
+
]);
|
|
78
|
+
await fortress.iam.bindRoleToServiceAccount(sa.id, role.id);
|
|
79
|
+
await fortress.iam.bindPermissionToServiceAccount(sa.id, {
|
|
80
|
+
resource: 'deploy',
|
|
81
|
+
action: 'promote',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await fortress.iam.deleteServiceAccount(sa.id);
|
|
85
|
+
|
|
86
|
+
// Account is gone
|
|
87
|
+
await expect(fortress.iam.getServiceAccount(sa.id)).rejects.toThrow(/not found/i);
|
|
88
|
+
|
|
89
|
+
// Role bindings are cleaned up
|
|
90
|
+
const roleBindings = await fortress.config.database.findMany({
|
|
91
|
+
model: 'role_binding',
|
|
92
|
+
where: [
|
|
93
|
+
{ field: 'subjectType', operator: '=', value: 'SERVICE_ACCOUNT' },
|
|
94
|
+
{ field: 'subjectId', operator: '=', value: sa.id },
|
|
95
|
+
],
|
|
96
|
+
});
|
|
97
|
+
expect(roleBindings).toHaveLength(0);
|
|
98
|
+
|
|
99
|
+
// Direct permission bindings are cleaned up
|
|
100
|
+
const directBindings = await fortress.config.database.findMany({
|
|
101
|
+
model: 'direct_permission_binding',
|
|
102
|
+
where: [
|
|
103
|
+
{ field: 'subjectType', operator: '=', value: 'SERVICE_ACCOUNT' },
|
|
104
|
+
{ field: 'subjectId', operator: '=', value: sa.id },
|
|
105
|
+
],
|
|
106
|
+
});
|
|
107
|
+
expect(directBindings).toHaveLength(0);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('iam-service: service account permissions (regression)', () => {
|
|
112
|
+
let fortress: Fortress;
|
|
113
|
+
|
|
114
|
+
beforeEach(() => {
|
|
115
|
+
fortress = createFortress({
|
|
116
|
+
jwt: { key: SECRET },
|
|
117
|
+
database: createTestAdapter(),
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Pins the core bug fix: SERVICE_ACCOUNT role bindings were silently dropped
|
|
122
|
+
// by the old getUserPermissions path. With getSubjectPermissions they must
|
|
123
|
+
// resolve end-to-end through checkPermission.
|
|
124
|
+
it('bindRoleToServiceAccount + checkPermission resolves the role', async () => {
|
|
125
|
+
const sa = await fortress.iam.createServiceAccount({ name: 'deployer' });
|
|
126
|
+
const role = await fortress.iam.createRole('ops-deployer', [
|
|
127
|
+
{ resource: 'deploy', action: 'run' },
|
|
128
|
+
]);
|
|
129
|
+
await fortress.iam.bindRoleToServiceAccount(sa.id, role.id);
|
|
130
|
+
|
|
131
|
+
const allowed = await fortress.iam.checkPermission(
|
|
132
|
+
{ type: 'SERVICE_ACCOUNT', id: sa.id },
|
|
133
|
+
'deploy',
|
|
134
|
+
'run',
|
|
135
|
+
);
|
|
136
|
+
expect(allowed).toBe(true);
|
|
137
|
+
|
|
138
|
+
const denied = await fortress.iam.checkPermission(
|
|
139
|
+
{ type: 'SERVICE_ACCOUNT', id: sa.id },
|
|
140
|
+
'deploy',
|
|
141
|
+
'destroy',
|
|
142
|
+
);
|
|
143
|
+
expect(denied).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('bindPermissionToServiceAccount grants the permission directly', async () => {
|
|
147
|
+
const sa = await fortress.iam.createServiceAccount({ name: 'audit-reader' });
|
|
148
|
+
await fortress.iam.bindPermissionToServiceAccount(sa.id, {
|
|
149
|
+
resource: 'audit',
|
|
150
|
+
action: 'read',
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const allowed = await fortress.iam.checkPermission(
|
|
154
|
+
{ type: 'SERVICE_ACCOUNT', id: sa.id },
|
|
155
|
+
'audit',
|
|
156
|
+
'read',
|
|
157
|
+
);
|
|
158
|
+
expect(allowed).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('unbindRoleFromServiceAccount revokes the role permissions', async () => {
|
|
162
|
+
const sa = await fortress.iam.createServiceAccount({ name: 'temp' });
|
|
163
|
+
const role = await fortress.iam.createRole('temp-role', [
|
|
164
|
+
{ resource: 'temp', action: 'use' },
|
|
165
|
+
]);
|
|
166
|
+
await fortress.iam.bindRoleToServiceAccount(sa.id, role.id);
|
|
167
|
+
expect(await fortress.iam.checkPermission({ type: 'SERVICE_ACCOUNT', id: sa.id }, 'temp', 'use')).toBe(true);
|
|
168
|
+
|
|
169
|
+
await fortress.iam.unbindRoleFromServiceAccount(sa.id, role.id);
|
|
170
|
+
expect(await fortress.iam.checkPermission({ type: 'SERVICE_ACCOUNT', id: sa.id }, 'temp', 'use')).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('inactive service account resolves to no permissions even with a bound role', async () => {
|
|
174
|
+
const sa = await fortress.iam.createServiceAccount({ name: 'disabled' });
|
|
175
|
+
const role = await fortress.iam.createRole('anything', [
|
|
176
|
+
{ resource: 'secret', action: 'read' },
|
|
177
|
+
]);
|
|
178
|
+
await fortress.iam.bindRoleToServiceAccount(sa.id, role.id);
|
|
179
|
+
|
|
180
|
+
// Flip isActive to false
|
|
181
|
+
await fortress.iam.updateServiceAccount(sa.id, { isActive: false });
|
|
182
|
+
|
|
183
|
+
const allowed = await fortress.iam.checkPermission(
|
|
184
|
+
{ type: 'SERVICE_ACCOUNT', id: sa.id },
|
|
185
|
+
'secret',
|
|
186
|
+
'read',
|
|
187
|
+
);
|
|
188
|
+
expect(allowed).toBe(false);
|
|
189
|
+
|
|
190
|
+
const perms = await fortress.iam.getPermissionsForSubject({
|
|
191
|
+
type: 'SERVICE_ACCOUNT',
|
|
192
|
+
id: sa.id,
|
|
193
|
+
});
|
|
194
|
+
expect(perms).toEqual([]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('service accounts do not inherit permissions from groups', async () => {
|
|
198
|
+
// A group has a permission; a user in the group has it via inheritance.
|
|
199
|
+
const user = await fortress.auth.createUser({
|
|
200
|
+
email: 'in-group@test.com',
|
|
201
|
+
name: 'In Group',
|
|
202
|
+
password: 'password-123456',
|
|
203
|
+
});
|
|
204
|
+
const group = await fortress.iam.createGroup('editors-sa-test');
|
|
205
|
+
await fortress.iam.addUserToGroup(group.id, user.id);
|
|
206
|
+
const role = await fortress.iam.createRole('group-editor', [
|
|
207
|
+
{ resource: 'article', action: 'edit' },
|
|
208
|
+
]);
|
|
209
|
+
await fortress.iam.bindRoleToGroup(group.id, role.id);
|
|
210
|
+
expect(await fortress.iam.checkPermission({ type: 'USER', id: user.id }, 'article', 'edit')).toBe(true);
|
|
211
|
+
|
|
212
|
+
// A service account with the same numeric id as the group must NOT
|
|
213
|
+
// pick up the group's permissions.
|
|
214
|
+
const sa = await fortress.iam.createServiceAccount({ name: 'isolated-sa-group' });
|
|
215
|
+
const allowed = await fortress.iam.checkPermission(
|
|
216
|
+
{ type: 'SERVICE_ACCOUNT', id: sa.id },
|
|
217
|
+
'article',
|
|
218
|
+
'edit',
|
|
219
|
+
);
|
|
220
|
+
expect(allowed).toBe(false);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('iam-service: service accounts + tenancy', () => {
|
|
225
|
+
let fortress: Fortress;
|
|
226
|
+
|
|
227
|
+
beforeEach(() => {
|
|
228
|
+
fortress = createFortress({
|
|
229
|
+
jwt: { key: SECRET },
|
|
230
|
+
database: createTestAdapter(),
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('tenant-scoped role binding only applies in its tenant', async () => {
|
|
235
|
+
const sa = await fortress.iam.createServiceAccount({ name: 'tenant-bound' });
|
|
236
|
+
const role = await fortress.iam.createRole('tenant-writer', [
|
|
237
|
+
{ resource: 'docs', action: 'write' },
|
|
238
|
+
]);
|
|
239
|
+
await fortress.iam.bindRoleToServiceAccount(sa.id, role.id, 'tenant-a');
|
|
240
|
+
|
|
241
|
+
const inA = await fortress.iam.checkPermission(
|
|
242
|
+
{ type: 'SERVICE_ACCOUNT', id: sa.id },
|
|
243
|
+
'docs',
|
|
244
|
+
'write',
|
|
245
|
+
{ tenantId: 'tenant-a' },
|
|
246
|
+
);
|
|
247
|
+
expect(inA).toBe(true);
|
|
248
|
+
|
|
249
|
+
const inB = await fortress.iam.checkPermission(
|
|
250
|
+
{ type: 'SERVICE_ACCOUNT', id: sa.id },
|
|
251
|
+
'docs',
|
|
252
|
+
'write',
|
|
253
|
+
{ tenantId: 'tenant-b' },
|
|
254
|
+
);
|
|
255
|
+
expect(inB).toBe(false);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('global role binding applies in any tenant', async () => {
|
|
259
|
+
const sa = await fortress.iam.createServiceAccount({ name: 'global-bound' });
|
|
260
|
+
const role = await fortress.iam.createRole('global-reader', [
|
|
261
|
+
{ resource: 'audit', action: 'read' },
|
|
262
|
+
]);
|
|
263
|
+
await fortress.iam.bindRoleToServiceAccount(sa.id, role.id); // no tenantId
|
|
264
|
+
|
|
265
|
+
expect(
|
|
266
|
+
await fortress.iam.checkPermission(
|
|
267
|
+
{ type: 'SERVICE_ACCOUNT', id: sa.id },
|
|
268
|
+
'audit',
|
|
269
|
+
'read',
|
|
270
|
+
{ tenantId: 'tenant-a' },
|
|
271
|
+
),
|
|
272
|
+
).toBe(true);
|
|
273
|
+
expect(
|
|
274
|
+
await fortress.iam.checkPermission(
|
|
275
|
+
{ type: 'SERVICE_ACCOUNT', id: sa.id },
|
|
276
|
+
'audit',
|
|
277
|
+
'read',
|
|
278
|
+
{ tenantId: 'tenant-b' },
|
|
279
|
+
),
|
|
280
|
+
).toBe(true);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import type { DatabaseAdapter } from '../adapters/database';
|
|
2
|
+
import type { InternalAdapter } from './internal-adapter';
|
|
3
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
4
|
+
import { createTestAdapter } from '../testing';
|
|
5
|
+
import { createInternalAdapter } from './internal-adapter';
|
|
6
|
+
|
|
7
|
+
let db: DatabaseAdapter;
|
|
8
|
+
let adapter: InternalAdapter;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
db = createTestAdapter();
|
|
12
|
+
adapter = createInternalAdapter(db);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('findUserByIdentifier', () => {
|
|
16
|
+
it('finds user via login_identifier', async () => {
|
|
17
|
+
const user = await db.create<{ id: string }>({
|
|
18
|
+
model: 'user',
|
|
19
|
+
data: { email: 'alice@example.com', name: 'Alice', passwordHash: 'hashed', isActive: true },
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
await db.create({
|
|
23
|
+
model: 'login_identifier',
|
|
24
|
+
data: { userId: user.id, type: 'email', value: 'alice@example.com' },
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const found = await adapter.findUserByIdentifier('alice@example.com');
|
|
28
|
+
expect(found).not.toBeNull();
|
|
29
|
+
expect(found!.email).toBe('alice@example.com');
|
|
30
|
+
expect(found!.name).toBe('Alice');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('normalizes case and Unicode only for email identifier lookup', async () => {
|
|
34
|
+
const user = await db.create<{ id: string }>({
|
|
35
|
+
model: 'user',
|
|
36
|
+
data: { email: 'é@example.com', name: 'Unicode', passwordHash: 'hashed', isActive: true },
|
|
37
|
+
});
|
|
38
|
+
await db.create({
|
|
39
|
+
model: 'login_identifier',
|
|
40
|
+
data: { userId: user.id, type: 'email', value: 'é@example.com' },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect((await adapter.findUserByIdentifier('E\u0301@EXAMPLE.COM'))?.id).toBe(user.id);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('preserves exact case for non-email identifiers', async () => {
|
|
47
|
+
const user = await db.create<{ id: string }>({
|
|
48
|
+
model: 'user',
|
|
49
|
+
data: { email: 'case@example.com', name: 'Case', passwordHash: 'hashed', isActive: true },
|
|
50
|
+
});
|
|
51
|
+
await db.create({
|
|
52
|
+
model: 'login_identifier',
|
|
53
|
+
data: { userId: user.id, type: 'username', value: 'CaseUser' },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect((await adapter.findUserByIdentifier('CaseUser'))?.id).toBe(user.id);
|
|
57
|
+
expect(await adapter.findUserByIdentifier('caseuser')).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('falls back to email lookup when no login_identifier exists', async () => {
|
|
61
|
+
await db.create({
|
|
62
|
+
model: 'user',
|
|
63
|
+
data: { email: 'bob@example.com', name: 'Bob', passwordHash: 'hashed', isActive: true },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const found = await adapter.findUserByIdentifier('bob@example.com');
|
|
67
|
+
expect(found).not.toBeNull();
|
|
68
|
+
expect(found!.email).toBe('bob@example.com');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('returns null for unknown identifier', async () => {
|
|
72
|
+
const found = await adapter.findUserByIdentifier('nobody@example.com');
|
|
73
|
+
expect(found).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('getUserGroups', () => {
|
|
78
|
+
it('returns group names for a user', async () => {
|
|
79
|
+
const user = await db.create<{ id: string }>({
|
|
80
|
+
model: 'user',
|
|
81
|
+
data: { email: 'alice@example.com', name: 'Alice', passwordHash: 'hashed', isActive: true },
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const g1 = await db.create<{ id: string }>({ model: 'group', data: { name: 'admins' } });
|
|
85
|
+
const g2 = await db.create<{ id: string }>({ model: 'group', data: { name: 'editors' } });
|
|
86
|
+
|
|
87
|
+
await db.create({ model: 'group_user', data: { groupId: g1.id, userId: user.id } });
|
|
88
|
+
await db.create({ model: 'group_user', data: { groupId: g2.id, userId: user.id } });
|
|
89
|
+
|
|
90
|
+
const groups = await adapter.getUserGroups(user.id);
|
|
91
|
+
expect(groups).toHaveLength(2);
|
|
92
|
+
expect(groups).toContain('admins');
|
|
93
|
+
expect(groups).toContain('editors');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('returns empty array for user with no groups', async () => {
|
|
97
|
+
const user = await db.create<{ id: string }>({
|
|
98
|
+
model: 'user',
|
|
99
|
+
data: { email: 'loner@example.com', name: 'Loner', passwordHash: 'hashed', isActive: true },
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const groups = await adapter.getUserGroups(user.id);
|
|
103
|
+
expect(groups).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('findRefreshTokenByHash', () => {
|
|
108
|
+
it('finds a refresh token by hash', async () => {
|
|
109
|
+
const user = await db.create<{ id: string }>({
|
|
110
|
+
model: 'user',
|
|
111
|
+
data: { email: 'alice@example.com', name: 'Alice', passwordHash: 'hashed', isActive: true },
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await db.create({
|
|
115
|
+
model: 'refresh_token',
|
|
116
|
+
data: {
|
|
117
|
+
userId: user.id,
|
|
118
|
+
tokenHash: 'abc123hash',
|
|
119
|
+
tokenFamily: 'family-1',
|
|
120
|
+
isRevoked: false,
|
|
121
|
+
expiresAt: new Date(Date.now() + 86400000),
|
|
122
|
+
ipAddress: '127.0.0.1',
|
|
123
|
+
userAgent: 'test',
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const token = await adapter.findRefreshTokenByHash('abc123hash');
|
|
128
|
+
expect(token).not.toBeNull();
|
|
129
|
+
expect(token!.userId).toBe(user.id);
|
|
130
|
+
expect(token!.tokenFamily).toBe('family-1');
|
|
131
|
+
expect(token!.isRevoked).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('returns null for unknown hash', async () => {
|
|
135
|
+
const token = await adapter.findRefreshTokenByHash('nonexistent');
|
|
136
|
+
expect(token).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('getSubjectPermissions', () => {
|
|
141
|
+
it('resolves permissions through direct role binding', async () => {
|
|
142
|
+
const user = await db.create<{ id: string }>({
|
|
143
|
+
model: 'user',
|
|
144
|
+
data: { email: 'alice@example.com', name: 'Alice', passwordHash: 'hashed', isActive: true },
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await db.create({ model: 'resource', data: { name: 'document' } });
|
|
148
|
+
|
|
149
|
+
const permission = await db.create<{ id: string }>({
|
|
150
|
+
model: 'permission',
|
|
151
|
+
data: { resource: 'document', action: 'read', effect: 'ALLOW', description: 'read document' },
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const role = await db.create<{ id: string }>({
|
|
155
|
+
model: 'role',
|
|
156
|
+
data: { name: 'viewer' },
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await db.create({ model: 'role_permission', data: { roleId: role.id, permissionId: permission.id } });
|
|
160
|
+
await db.create({ model: 'role_binding', data: { roleId: role.id, subjectType: 'USER', subjectId: user.id } });
|
|
161
|
+
|
|
162
|
+
const permissions = await adapter.getSubjectPermissions({ type: 'USER', id: user.id });
|
|
163
|
+
expect(permissions).toHaveLength(1);
|
|
164
|
+
expect(permissions[0].resource).toBe('document');
|
|
165
|
+
expect(permissions[0].action).toBe('read');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('resolves permissions through group role binding', async () => {
|
|
169
|
+
const user = await db.create<{ id: string }>({
|
|
170
|
+
model: 'user',
|
|
171
|
+
data: { email: 'bob@example.com', name: 'Bob', passwordHash: 'hashed', isActive: true },
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const group = await db.create<{ id: string }>({ model: 'group', data: { name: 'editors' } });
|
|
175
|
+
await db.create({ model: 'group_user', data: { groupId: group.id, userId: user.id } });
|
|
176
|
+
|
|
177
|
+
await db.create({ model: 'resource', data: { name: 'article' } });
|
|
178
|
+
|
|
179
|
+
const permission = await db.create<{ id: string }>({
|
|
180
|
+
model: 'permission',
|
|
181
|
+
data: { resource: 'article', action: 'write', effect: 'ALLOW', description: 'write article' },
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const role = await db.create<{ id: string }>({
|
|
185
|
+
model: 'role',
|
|
186
|
+
data: { name: 'editor' },
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
await db.create({ model: 'role_permission', data: { roleId: role.id, permissionId: permission.id } });
|
|
190
|
+
await db.create({ model: 'role_binding', data: { roleId: role.id, subjectType: 'GROUP', subjectId: group.id } });
|
|
191
|
+
|
|
192
|
+
const permissions = await adapter.getSubjectPermissions({ type: 'USER', id: user.id });
|
|
193
|
+
expect(permissions).toHaveLength(1);
|
|
194
|
+
expect(permissions[0].resource).toBe('article');
|
|
195
|
+
expect(permissions[0].action).toBe('write');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('does not let tenant-scoped role bindings satisfy tenant-less permission resolution', async () => {
|
|
199
|
+
const user = await db.create<{ id: string }>({
|
|
200
|
+
model: 'user',
|
|
201
|
+
data: { email: 'tenant@example.com', name: 'Tenant User', passwordHash: 'hashed', isActive: true },
|
|
202
|
+
});
|
|
203
|
+
await db.create({ model: 'resource', data: { name: 'invoice' } });
|
|
204
|
+
const permission = await db.create<{ id: string }>({
|
|
205
|
+
model: 'permission',
|
|
206
|
+
data: { resource: 'invoice', action: 'read', effect: 'ALLOW', description: 'read invoice' },
|
|
207
|
+
});
|
|
208
|
+
const role = await db.create<{ id: string }>({ model: 'role', data: { name: 'tenant-reader' } });
|
|
209
|
+
await db.create({ model: 'role_permission', data: { roleId: role.id, permissionId: permission.id } });
|
|
210
|
+
await db.create({ model: 'role_binding', data: { roleId: role.id, subjectType: 'USER', subjectId: user.id, tenantId: 'tenant-a' } });
|
|
211
|
+
|
|
212
|
+
expect(await adapter.getSubjectPermissions({ type: 'USER', id: user.id })).toEqual([]);
|
|
213
|
+
const tenantPermissions = await adapter.getSubjectPermissions({ type: 'USER', id: user.id }, 'tenant-a');
|
|
214
|
+
expect(tenantPermissions).toHaveLength(1);
|
|
215
|
+
expect(tenantPermissions[0].resource).toBe('invoice');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('returns empty array for user with no roles', async () => {
|
|
219
|
+
const user = await db.create<{ id: string }>({
|
|
220
|
+
model: 'user',
|
|
221
|
+
data: { email: 'nobody@example.com', name: 'Nobody', passwordHash: 'hashed', isActive: true },
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const permissions = await adapter.getSubjectPermissions({ type: 'USER', id: user.id });
|
|
225
|
+
expect(permissions).toEqual([]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('returns empty for an inactive USER even with a bound role', async () => {
|
|
229
|
+
const user = await db.create<{ id: string }>({
|
|
230
|
+
model: 'user',
|
|
231
|
+
data: { email: 'disabled@example.com', name: 'Disabled', passwordHash: 'hashed', isActive: false },
|
|
232
|
+
});
|
|
233
|
+
await db.create({ model: 'resource', data: { name: 'document-disabled' } });
|
|
234
|
+
const permission = await db.create<{ id: string }>({
|
|
235
|
+
model: 'permission',
|
|
236
|
+
data: { resource: 'document-disabled', action: 'read', effect: 'ALLOW', description: null },
|
|
237
|
+
});
|
|
238
|
+
const role = await db.create<{ id: string }>({ model: 'role', data: { name: 'disabled-reader' } });
|
|
239
|
+
await db.create({ model: 'role_permission', data: { roleId: role.id, permissionId: permission.id } });
|
|
240
|
+
await db.create({ model: 'role_binding', data: { roleId: role.id, subjectType: 'USER', subjectId: user.id } });
|
|
241
|
+
|
|
242
|
+
await expect(adapter.getSubjectPermissions({ type: 'USER', id: user.id })).resolves.toEqual([]);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('returns empty for a missing USER even when stale bindings remain', async () => {
|
|
246
|
+
await db.create({ model: 'resource', data: { name: 'stale-document' } });
|
|
247
|
+
const permission = await db.create<{ id: string }>({
|
|
248
|
+
model: 'permission',
|
|
249
|
+
data: { resource: 'stale-document', action: 'read', effect: 'ALLOW', description: null },
|
|
250
|
+
});
|
|
251
|
+
const role = await db.create<{ id: string }>({ model: 'role', data: { name: 'stale-reader' } });
|
|
252
|
+
await db.create({ model: 'role_permission', data: { roleId: role.id, permissionId: permission.id } });
|
|
253
|
+
await db.create({ model: 'role_binding', data: { roleId: role.id, subjectType: 'USER', subjectId: '99999' } });
|
|
254
|
+
|
|
255
|
+
await expect(adapter.getSubjectPermissions({ type: 'USER', id: '99999' })).resolves.toEqual([]);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('resolves permissions through a SERVICE_ACCOUNT role binding', async () => {
|
|
259
|
+
const sa = await db.create<{ id: string }>({
|
|
260
|
+
model: 'service_account',
|
|
261
|
+
data: { name: 'ci-deploy', displayName: 'CI Deploy', description: null, isActive: true },
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
await db.create({ model: 'resource', data: { name: 'deploy' } });
|
|
265
|
+
|
|
266
|
+
const permission = await db.create<{ id: string }>({
|
|
267
|
+
model: 'permission',
|
|
268
|
+
data: { resource: 'deploy', action: 'run', effect: 'ALLOW', description: 'run deploy' },
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const role = await db.create<{ id: string }>({
|
|
272
|
+
model: 'role',
|
|
273
|
+
data: { name: 'deployer' },
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
await db.create({ model: 'role_permission', data: { roleId: role.id, permissionId: permission.id } });
|
|
277
|
+
await db.create({ model: 'role_binding', data: { roleId: role.id, subjectType: 'SERVICE_ACCOUNT', subjectId: sa.id } });
|
|
278
|
+
|
|
279
|
+
const permissions = await adapter.getSubjectPermissions({ type: 'SERVICE_ACCOUNT', id: sa.id });
|
|
280
|
+
expect(permissions).toHaveLength(1);
|
|
281
|
+
expect(permissions[0].resource).toBe('deploy');
|
|
282
|
+
expect(permissions[0].action).toBe('run');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('resolves direct permission bindings for a SERVICE_ACCOUNT', async () => {
|
|
286
|
+
const sa = await db.create<{ id: string }>({
|
|
287
|
+
model: 'service_account',
|
|
288
|
+
data: { name: 'audit-reader', displayName: null, description: null, isActive: true },
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
await db.create({ model: 'resource', data: { name: 'audit' } });
|
|
292
|
+
const permission = await db.create<{ id: string }>({
|
|
293
|
+
model: 'permission',
|
|
294
|
+
data: { resource: 'audit', action: 'read', effect: 'ALLOW', description: null },
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
await db.create({
|
|
298
|
+
model: 'direct_permission_binding',
|
|
299
|
+
data: { permissionId: permission.id, subjectType: 'SERVICE_ACCOUNT', subjectId: sa.id },
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const permissions = await adapter.getSubjectPermissions({ type: 'SERVICE_ACCOUNT', id: sa.id });
|
|
303
|
+
expect(permissions).toHaveLength(1);
|
|
304
|
+
expect(permissions[0].resource).toBe('audit');
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('returns empty for an inactive SERVICE_ACCOUNT even with a bound role', async () => {
|
|
308
|
+
const sa = await db.create<{ id: string }>({
|
|
309
|
+
model: 'service_account',
|
|
310
|
+
data: { name: 'disabled-sa', displayName: null, description: null, isActive: false },
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
await db.create({ model: 'resource', data: { name: 'deploy' } });
|
|
314
|
+
const permission = await db.create<{ id: string }>({
|
|
315
|
+
model: 'permission',
|
|
316
|
+
data: { resource: 'deploy', action: 'run', effect: 'ALLOW', description: null },
|
|
317
|
+
});
|
|
318
|
+
const role = await db.create<{ id: string }>({ model: 'role', data: { name: 'deployer-disabled' } });
|
|
319
|
+
await db.create({ model: 'role_permission', data: { roleId: role.id, permissionId: permission.id } });
|
|
320
|
+
await db.create({ model: 'role_binding', data: { roleId: role.id, subjectType: 'SERVICE_ACCOUNT', subjectId: sa.id } });
|
|
321
|
+
|
|
322
|
+
const permissions = await adapter.getSubjectPermissions({ type: 'SERVICE_ACCOUNT', id: sa.id });
|
|
323
|
+
expect(permissions).toEqual([]);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('service account subjects do not inherit permissions from groups', async () => {
|
|
327
|
+
// Set up a group with a permission, and a user in that group.
|
|
328
|
+
const group = await db.create<{ id: string }>({ model: 'group', data: { name: 'inheritance-group' } });
|
|
329
|
+
await db.create({ model: 'resource', data: { name: 'article' } });
|
|
330
|
+
const permission = await db.create<{ id: string }>({
|
|
331
|
+
model: 'permission',
|
|
332
|
+
data: { resource: 'article', action: 'read', effect: 'ALLOW', description: null },
|
|
333
|
+
});
|
|
334
|
+
const role = await db.create<{ id: string }>({ model: 'role', data: { name: 'reader-inherit' } });
|
|
335
|
+
await db.create({ model: 'role_permission', data: { roleId: role.id, permissionId: permission.id } });
|
|
336
|
+
await db.create({ model: 'role_binding', data: { roleId: role.id, subjectType: 'GROUP', subjectId: group.id } });
|
|
337
|
+
|
|
338
|
+
// Create a service account with the SAME numeric id as the group — group-walk
|
|
339
|
+
// must not accidentally match service accounts.
|
|
340
|
+
const sa = await db.create<{ id: string }>({
|
|
341
|
+
model: 'service_account',
|
|
342
|
+
data: { name: 'isolated-sa', displayName: null, description: null, isActive: true },
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const permissions = await adapter.getSubjectPermissions({ type: 'SERVICE_ACCOUNT', id: sa.id });
|
|
346
|
+
expect(permissions).toEqual([]);
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
describe('ensureResource', () => {
|
|
351
|
+
it('creates a resource if it does not exist', async () => {
|
|
352
|
+
await adapter.ensureResource('invoice');
|
|
353
|
+
|
|
354
|
+
const found = await db.findOne<{ name: string }>({
|
|
355
|
+
model: 'resource',
|
|
356
|
+
where: [{ field: 'name', operator: '=', value: 'invoice' }],
|
|
357
|
+
});
|
|
358
|
+
expect(found).not.toBeNull();
|
|
359
|
+
expect(found!.name).toBe('invoice');
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('is a no-op if resource already exists', async () => {
|
|
363
|
+
await db.create({ model: 'resource', data: { name: 'invoice' } });
|
|
364
|
+
await adapter.ensureResource('invoice');
|
|
365
|
+
|
|
366
|
+
const count = await db.count({ model: 'resource', where: [{ field: 'name', operator: '=', value: 'invoice' }] });
|
|
367
|
+
expect(count).toBe(1);
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
describe('findOrCreatePermission', () => {
|
|
372
|
+
it('creates a new permission', async () => {
|
|
373
|
+
await db.create({ model: 'resource', data: { name: 'report' } });
|
|
374
|
+
|
|
375
|
+
const perm = await adapter.findOrCreatePermission({ resource: 'report', action: 'generate' });
|
|
376
|
+
expect(perm.resource).toBe('report');
|
|
377
|
+
expect(perm.action).toBe('generate');
|
|
378
|
+
expect(perm.effect).toBe('ALLOW');
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('returns existing permission on duplicate call', async () => {
|
|
382
|
+
await db.create({ model: 'resource', data: { name: 'report' } });
|
|
383
|
+
|
|
384
|
+
const first = await adapter.findOrCreatePermission({ resource: 'report', action: 'generate' });
|
|
385
|
+
const second = await adapter.findOrCreatePermission({ resource: 'report', action: 'generate' });
|
|
386
|
+
|
|
387
|
+
expect(first.id).toBe(second.id);
|
|
388
|
+
});
|
|
389
|
+
});
|