@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,198 @@
|
|
|
1
|
+
import type { Permission, PermissionCondition, PermissionContext } from '../types';
|
|
2
|
+
|
|
3
|
+
const VARIABLE_PATTERN = /^\$\{(.+)\}$/;
|
|
4
|
+
|
|
5
|
+
export type EvaluationMode = 'allow-only' | 'deny-overrides';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Check whether a credential-level scope set allows the requested
|
|
9
|
+
* resource/action. `null`/`undefined` means the credential is unscoped and
|
|
10
|
+
* inherits the subject's full IAM permissions; an empty array allows nothing.
|
|
11
|
+
*/
|
|
12
|
+
export function withinCredentialScope(
|
|
13
|
+
scopes: string[] | null | undefined,
|
|
14
|
+
resource: string,
|
|
15
|
+
action: string,
|
|
16
|
+
): boolean {
|
|
17
|
+
if (scopes == null)
|
|
18
|
+
return true;
|
|
19
|
+
return scopes.some(scope =>
|
|
20
|
+
scope === '*'
|
|
21
|
+
|| scope === `${resource}:*`
|
|
22
|
+
|| scope === `${resource}:${action}`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Evaluate a set of permissions against a resource+action request.
|
|
28
|
+
*
|
|
29
|
+
* - 'allow-only': if any ALLOW matches → allow, otherwise deny
|
|
30
|
+
* - 'deny-overrides' (AWS-style):
|
|
31
|
+
* 1. Collect all matching permissions
|
|
32
|
+
* 2. If any DENY matches → deny (overrides everything)
|
|
33
|
+
* 3. If any ALLOW matches → allow
|
|
34
|
+
* 4. Otherwise → deny (implicit)
|
|
35
|
+
*/
|
|
36
|
+
export function evaluatePermissions(
|
|
37
|
+
permissions: Permission[],
|
|
38
|
+
resource: string,
|
|
39
|
+
action: string,
|
|
40
|
+
mode: EvaluationMode,
|
|
41
|
+
context?: PermissionContext,
|
|
42
|
+
): boolean {
|
|
43
|
+
const matching = permissions.filter(p =>
|
|
44
|
+
matchesResourceAction(p, resource, action),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
if (matching.length === 0)
|
|
48
|
+
return false;
|
|
49
|
+
|
|
50
|
+
// Evaluate conditions on each matching permission
|
|
51
|
+
const evaluated = matching.map(p => ({
|
|
52
|
+
effect: p.effect,
|
|
53
|
+
conditionsMet: !p.conditions?.length || evaluateConditions(p.conditions, context),
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
// Only consider permissions where conditions are met
|
|
57
|
+
const effective = evaluated.filter(e => e.conditionsMet);
|
|
58
|
+
|
|
59
|
+
if (effective.length === 0)
|
|
60
|
+
return false;
|
|
61
|
+
|
|
62
|
+
if (mode === 'deny-overrides') {
|
|
63
|
+
// Any DENY → deny (overrides everything)
|
|
64
|
+
if (effective.some(e => e.effect === 'DENY'))
|
|
65
|
+
return false;
|
|
66
|
+
// Any ALLOW → allow
|
|
67
|
+
return effective.some(e => e.effect === 'ALLOW');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// allow-only: any ALLOW → allow
|
|
71
|
+
return effective.some(e => e.effect === 'ALLOW');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function matchesResourceAction(permission: Permission, resource: string, action: string): boolean {
|
|
75
|
+
return matchesWildcard(permission.resource, resource)
|
|
76
|
+
&& matchesWildcard(permission.action, action);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function matchesWildcard(pattern: string, value: string): boolean {
|
|
80
|
+
if (pattern === '*')
|
|
81
|
+
return true;
|
|
82
|
+
return pattern === value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Evaluate all conditions. All conditions must be true (AND logic).
|
|
87
|
+
*/
|
|
88
|
+
export function evaluateConditions(
|
|
89
|
+
conditions: PermissionCondition[],
|
|
90
|
+
context?: PermissionContext,
|
|
91
|
+
): boolean {
|
|
92
|
+
if (!context)
|
|
93
|
+
return false;
|
|
94
|
+
|
|
95
|
+
return conditions.every(condition => evaluateCondition(condition, context));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function evaluateCondition(condition: PermissionCondition, context: PermissionContext): boolean {
|
|
99
|
+
const actualValue = resolveFieldValue(condition.field, context);
|
|
100
|
+
const expectedValue = resolveExpectedValue(condition.value, context);
|
|
101
|
+
// Missing fields/references are never comparable. In particular, `neq`
|
|
102
|
+
// must not turn an unresolved value into an accidental grant.
|
|
103
|
+
if (
|
|
104
|
+
actualValue === undefined
|
|
105
|
+
|| expectedValue === undefined
|
|
106
|
+
|| (Array.isArray(expectedValue) && expectedValue.includes(undefined))
|
|
107
|
+
) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
switch (condition.operator) {
|
|
112
|
+
case 'eq':
|
|
113
|
+
return String(actualValue) === String(expectedValue);
|
|
114
|
+
case 'neq':
|
|
115
|
+
return String(actualValue) !== String(expectedValue);
|
|
116
|
+
case 'in': {
|
|
117
|
+
const list = Array.isArray(expectedValue) ? expectedValue : [expectedValue];
|
|
118
|
+
return list.map(String).includes(String(actualValue));
|
|
119
|
+
}
|
|
120
|
+
case 'startsWith':
|
|
121
|
+
return String(actualValue).startsWith(String(expectedValue));
|
|
122
|
+
default:
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Resolve a dotted field path from the permission context.
|
|
129
|
+
* e.g., "resource.ownerId" → context.resource.ownerId
|
|
130
|
+
*/
|
|
131
|
+
function resolveFieldValue(field: string, context: PermissionContext): unknown {
|
|
132
|
+
const [section, ...rest] = field.split('.');
|
|
133
|
+
const key = rest.join('.');
|
|
134
|
+
|
|
135
|
+
let source: Record<string, unknown> | undefined;
|
|
136
|
+
if (section === 'resource')
|
|
137
|
+
source = context.resource;
|
|
138
|
+
else if (section === 'request')
|
|
139
|
+
source = context.request;
|
|
140
|
+
else if (section === 'user')
|
|
141
|
+
source = context.user;
|
|
142
|
+
|
|
143
|
+
if (!source || !key)
|
|
144
|
+
return undefined;
|
|
145
|
+
|
|
146
|
+
return getNestedValue(source, key);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Resolve expected value — supports both ${variable} template syntax
|
|
151
|
+
* and structured ConditionRef objects ({ ref: "user.id" }).
|
|
152
|
+
*/
|
|
153
|
+
function resolveExpectedValue(
|
|
154
|
+
value: string | string[] | { ref: string } | { ref: string }[],
|
|
155
|
+
context: PermissionContext,
|
|
156
|
+
): unknown {
|
|
157
|
+
if (Array.isArray(value)) {
|
|
158
|
+
return value.map((v) => {
|
|
159
|
+
if (typeof v === 'object' && 'ref' in v)
|
|
160
|
+
return resolveFieldValue(v.ref, context);
|
|
161
|
+
return resolveSingleValue(v, context);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if (typeof value === 'object' && 'ref' in value)
|
|
165
|
+
return resolveFieldValue(value.ref, context);
|
|
166
|
+
return resolveSingleValue(value, context);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function resolveSingleValue(value: string, context: PermissionContext): unknown {
|
|
170
|
+
const match = VARIABLE_PATTERN.exec(value);
|
|
171
|
+
if (!match)
|
|
172
|
+
return value;
|
|
173
|
+
|
|
174
|
+
// It's a variable reference like ${user.id}
|
|
175
|
+
return resolveFieldValue(match[1], context);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// L-tier: prototype-pollution-safe key set. Reading `__proto__` /
|
|
179
|
+
// `constructor` / `prototype` off an attacker-controlled object would
|
|
180
|
+
// either return the prototype chain or let a condition spoof an
|
|
181
|
+
// inherited value as a real field. We skip these keys defensively even
|
|
182
|
+
// though all currently-known callers feed plain object literals.
|
|
183
|
+
const FORBIDDEN_PROTO_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
184
|
+
|
|
185
|
+
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
|
186
|
+
const parts = path.split('.');
|
|
187
|
+
let current: unknown = obj;
|
|
188
|
+
|
|
189
|
+
for (const part of parts) {
|
|
190
|
+
if (current === null || current === undefined || typeof current !== 'object')
|
|
191
|
+
return undefined;
|
|
192
|
+
if (FORBIDDEN_PROTO_KEYS.has(part))
|
|
193
|
+
return undefined;
|
|
194
|
+
current = (current as Record<string, unknown>)[part];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return current;
|
|
198
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { Fortress } from '../fortress';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { createTestAdapter } from '../../testing';
|
|
4
|
+
import { createFortress } from '../fortress';
|
|
5
|
+
import { endpoint, obj, str } from '../schema-builder';
|
|
6
|
+
|
|
7
|
+
const SECRET = 'permission-sync-test-secret-3232!';
|
|
8
|
+
|
|
9
|
+
async function buildFortress(): Promise<Fortress> {
|
|
10
|
+
const fortress = createFortress({
|
|
11
|
+
jwt: { key: SECRET },
|
|
12
|
+
database: createTestAdapter(),
|
|
13
|
+
routes: {
|
|
14
|
+
listSchools: endpoint('GET', '/schools')
|
|
15
|
+
.summary('List schools')
|
|
16
|
+
.security('bearer')
|
|
17
|
+
.permission('school', 'list')
|
|
18
|
+
.response(200, 'OK', obj({ data: str() }, 'data'))
|
|
19
|
+
.handler('listSchools')
|
|
20
|
+
.build(),
|
|
21
|
+
readSchool: endpoint('GET', '/schools/:id')
|
|
22
|
+
.summary('Read school')
|
|
23
|
+
.security('bearer')
|
|
24
|
+
.permission('school', 'read')
|
|
25
|
+
.params(obj({ id: str() }, 'id'))
|
|
26
|
+
.response(200, 'OK', obj({ data: str() }, 'data'))
|
|
27
|
+
.handler('readSchool')
|
|
28
|
+
.build(),
|
|
29
|
+
createSchool: endpoint('POST', '/schools')
|
|
30
|
+
.summary('Create school')
|
|
31
|
+
.security('bearer')
|
|
32
|
+
.permission('school', 'create')
|
|
33
|
+
.response(201, 'Created', obj({ data: str() }, 'data'))
|
|
34
|
+
.handler('createSchool')
|
|
35
|
+
.build(),
|
|
36
|
+
// Endpoint without a permission declaration — should be ignored.
|
|
37
|
+
ping: endpoint('GET', '/ping')
|
|
38
|
+
.summary('Ping')
|
|
39
|
+
.security('none')
|
|
40
|
+
.response(200, 'OK', obj({ ok: str() }, 'ok'))
|
|
41
|
+
.handler('ping')
|
|
42
|
+
.build(),
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
await fortress.migrate();
|
|
46
|
+
return fortress;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('fortress.syncPermissionsFromManifest', () => {
|
|
50
|
+
it('discovers unique (resource, action) pairs and creates each permission', async () => {
|
|
51
|
+
const fortress = await buildFortress();
|
|
52
|
+
const result = await fortress.syncPermissionsFromManifest();
|
|
53
|
+
|
|
54
|
+
// Default scope is fortress.endpoints, which includes the 3 host
|
|
55
|
+
// perms plus the system permissions declared on fortress's own
|
|
56
|
+
// auth/IAM endpoints. We assert the host perms made it in and that
|
|
57
|
+
// every discovered perm was created (none existed before).
|
|
58
|
+
expect(result.discovered).toBeGreaterThanOrEqual(3);
|
|
59
|
+
expect(result.created).toBe(result.discovered);
|
|
60
|
+
expect(result.existing).toBe(0);
|
|
61
|
+
|
|
62
|
+
const perms = await fortress.iam.listPermissions();
|
|
63
|
+
const keys = perms.map(p => `${p.resource}:${p.action}`);
|
|
64
|
+
expect(keys).toContain('school:create');
|
|
65
|
+
expect(keys).toContain('school:list');
|
|
66
|
+
expect(keys).toContain('school:read');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('is idempotent on a partially seeded database', async () => {
|
|
70
|
+
const fortress = await buildFortress();
|
|
71
|
+
await fortress.iam.createPermission({ resource: 'school', action: 'list' });
|
|
72
|
+
|
|
73
|
+
const first = await fortress.syncPermissionsFromManifest();
|
|
74
|
+
// The pre-seeded school:list is the only existing perm; everything
|
|
75
|
+
// else discovered should be a fresh insert.
|
|
76
|
+
expect(first.existing).toBe(1);
|
|
77
|
+
expect(first.created).toBe(first.discovered - 1);
|
|
78
|
+
|
|
79
|
+
// Re-running adds nothing.
|
|
80
|
+
const second = await fortress.syncPermissionsFromManifest();
|
|
81
|
+
expect(second.created).toBe(0);
|
|
82
|
+
expect(second.existing).toBe(second.discovered);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('binds manifest-discovered permissions onto a role when spec is "*"', async () => {
|
|
86
|
+
const fortress = await buildFortress();
|
|
87
|
+
// A stale/unrelated permission in the DB must not be pulled in by '*'.
|
|
88
|
+
await fortress.iam.createPermission({ resource: 'stale', action: 'delete' });
|
|
89
|
+
|
|
90
|
+
const result = await fortress.syncPermissionsFromManifest({
|
|
91
|
+
defaultRoles: { admin: '*' },
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(result.roles.admin?.created).toBe(true);
|
|
95
|
+
expect(result.roles.admin?.bound).toBe(result.discovered);
|
|
96
|
+
|
|
97
|
+
const roles = await fortress.iam.getRoles();
|
|
98
|
+
const admin = roles.find(r => r.name === 'admin');
|
|
99
|
+
expect(admin).toBeDefined();
|
|
100
|
+
const detail = await fortress.iam.getRole(admin!.id);
|
|
101
|
+
expect(detail.permissions.length).toBe(result.discovered);
|
|
102
|
+
// Spot-check that the host perms are among them and stale DB-only perms are not.
|
|
103
|
+
const keys = detail.permissions.map(p => `${p.resource}:${p.action}`);
|
|
104
|
+
expect(keys).toContain('school:create');
|
|
105
|
+
expect(keys).not.toContain('stale:delete');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('binds only the listed permissions when spec is a string list', async () => {
|
|
109
|
+
const fortress = await buildFortress();
|
|
110
|
+
const result = await fortress.syncPermissionsFromManifest({
|
|
111
|
+
defaultRoles: { member: ['school:read', 'school:list'] },
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
expect(result.roles.member?.bound).toBe(2);
|
|
115
|
+
|
|
116
|
+
const roles = await fortress.iam.getRoles();
|
|
117
|
+
const member = roles.find(r => r.name === 'member')!;
|
|
118
|
+
const detail = await fortress.iam.getRole(member.id);
|
|
119
|
+
const keys = detail.permissions.map(p => `${p.resource}:${p.action}`).sort();
|
|
120
|
+
expect(keys).toEqual(['school:list', 'school:read']);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('only grants on re-run — never revokes', async () => {
|
|
124
|
+
const fortress = await buildFortress();
|
|
125
|
+
await fortress.syncPermissionsFromManifest({
|
|
126
|
+
defaultRoles: { member: ['school:read', 'school:list', 'school:create'] },
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Subsequent call asks for fewer perms — existing should stay, but the
|
|
130
|
+
// report should not count already-bound permissions as newly bound.
|
|
131
|
+
const second = await fortress.syncPermissionsFromManifest({
|
|
132
|
+
defaultRoles: { member: ['school:read'] },
|
|
133
|
+
});
|
|
134
|
+
expect(second.roles.member?.bound).toBe(0);
|
|
135
|
+
|
|
136
|
+
const roles = await fortress.iam.getRoles();
|
|
137
|
+
const member = roles.find(r => r.name === 'member')!;
|
|
138
|
+
const detail = await fortress.iam.getRole(member.id);
|
|
139
|
+
const keys = detail.permissions.map(p => `${p.resource}:${p.action}`).sort();
|
|
140
|
+
expect(keys).toEqual(['school:create', 'school:list', 'school:read']);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('rejects an invalid role-permission spec', async () => {
|
|
144
|
+
const fortress = await buildFortress();
|
|
145
|
+
await expect(
|
|
146
|
+
fortress.syncPermissionsFromManifest({ defaultRoles: { admin: ['nocolon'] } }),
|
|
147
|
+
).rejects.toThrow(/Invalid permission spec/);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('accepts an explicit endpoints array', async () => {
|
|
151
|
+
const fortress = await buildFortress();
|
|
152
|
+
const result = await fortress.syncPermissionsFromManifest({
|
|
153
|
+
endpoints: [
|
|
154
|
+
endpoint('GET', '/x')
|
|
155
|
+
.summary('x')
|
|
156
|
+
.security('bearer')
|
|
157
|
+
.permission('widget', 'twiddle')
|
|
158
|
+
.response(200, 'OK')
|
|
159
|
+
.handler('x')
|
|
160
|
+
.build(),
|
|
161
|
+
],
|
|
162
|
+
});
|
|
163
|
+
expect(result.discovered).toBe(1);
|
|
164
|
+
expect(result.created).toBe(1);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manifest-driven permission seeding.
|
|
3
|
+
*
|
|
4
|
+
* `endpoint().permission(resource, action)` declares the permission an
|
|
5
|
+
* endpoint needs, but a freshly migrated database has no `Permission` row
|
|
6
|
+
* for it. Every consumer used to write the same seed script: walk
|
|
7
|
+
* `fortress.endpoints`, dedupe `(resource, action)` pairs, call
|
|
8
|
+
* `iam.createPermission(...)` for each, optionally bind defaults onto a
|
|
9
|
+
* couple of well-known roles.
|
|
10
|
+
*
|
|
11
|
+
* This module replaces that script. It's idempotent (re-runs safely on a
|
|
12
|
+
* partially seeded DB) and exposed on the {@link Fortress} instance as
|
|
13
|
+
* `fortress.syncPermissionsFromManifest()`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { EndpointDefinition } from '../endpoint';
|
|
17
|
+
import type { Permission } from '../types';
|
|
18
|
+
import type { IamService } from './iam-service';
|
|
19
|
+
import { Errors } from '../errors';
|
|
20
|
+
|
|
21
|
+
/** Options accepted by {@link runPermissionSync}. */
|
|
22
|
+
export interface PermissionSyncOptions {
|
|
23
|
+
/**
|
|
24
|
+
* Endpoints to scan for `meta.permission` declarations. Defaults to the
|
|
25
|
+
* full set registered on the Fortress instance when called via
|
|
26
|
+
* {@link import('../fortress').Fortress.syncPermissionsFromManifest}.
|
|
27
|
+
*/
|
|
28
|
+
endpoints?: EndpointDefinition[];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Optional role-name → permissions map. For each role:
|
|
32
|
+
*
|
|
33
|
+
* - `'*'` binds every permission discovered in `endpoints` to the role.
|
|
34
|
+
* - `['resource:action', ...]` binds only the named permissions (one
|
|
35
|
+
* string per `(resource, action)` pair, colon-separated).
|
|
36
|
+
*
|
|
37
|
+
* Roles are created on demand; existing roles get any missing
|
|
38
|
+
* permissions added. Permissions on a role that aren't requested here
|
|
39
|
+
* are left in place \u2014 this only grants, never revokes.
|
|
40
|
+
*
|
|
41
|
+
* ```ts
|
|
42
|
+
* await fortress.syncPermissionsFromManifest({\n * defaultRoles: {\n * admin: '*',\n * member: ['school:read', 'school:list'],\n * },\n * });\n * ```
|
|
43
|
+
*/
|
|
44
|
+
defaultRoles?: Record<string, '*' | readonly string[]>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Result returned by {@link runPermissionSync}. */
|
|
48
|
+
export interface PermissionSyncResult {
|
|
49
|
+
/** Number of unique `(resource, action)` pairs discovered. */
|
|
50
|
+
discovered: number;
|
|
51
|
+
/**
|
|
52
|
+
* Number of permissions that already existed in the database before
|
|
53
|
+
* the sync started. (The adapter's `findOrCreatePermission` doesn't
|
|
54
|
+
* distinguish, so this is computed by counting pre-existing
|
|
55
|
+
* permissions; new inserts = `discovered - existing`.)
|
|
56
|
+
*/
|
|
57
|
+
existing: number;
|
|
58
|
+
/** Number of new permission rows inserted by this sync run. */
|
|
59
|
+
created: number;
|
|
60
|
+
/**
|
|
61
|
+
* Roles touched by `defaultRoles`, keyed by role name. `bound` is the
|
|
62
|
+
* number of permission bindings newly added during this sync run.
|
|
63
|
+
* Already-bound permissions do not double-count.
|
|
64
|
+
*/
|
|
65
|
+
roles: Record<string, { created: boolean; bound: number }>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface PermissionKey {
|
|
69
|
+
resource: string;
|
|
70
|
+
action: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function collectUniquePermissions(endpoints: EndpointDefinition[]): PermissionKey[] {
|
|
74
|
+
const seen = new Set<string>();
|
|
75
|
+
const result: PermissionKey[] = [];
|
|
76
|
+
for (const ep of endpoints) {
|
|
77
|
+
const perm = ep.meta?.permission;
|
|
78
|
+
if (!perm)
|
|
79
|
+
continue;
|
|
80
|
+
const key = `${perm.resource}:${perm.action}`;
|
|
81
|
+
if (seen.has(key))
|
|
82
|
+
continue;
|
|
83
|
+
seen.add(key);
|
|
84
|
+
result.push({ resource: perm.resource, action: perm.action });
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseRolePermSpec(spec: string): PermissionKey {
|
|
90
|
+
const colon = spec.indexOf(':');
|
|
91
|
+
if (colon < 1 || colon === spec.length - 1) {
|
|
92
|
+
throw Errors.badRequest(
|
|
93
|
+
`Invalid permission spec '${spec}'; expected '<resource>:<action>'`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return { resource: spec.slice(0, colon), action: spec.slice(colon + 1) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function dedupePermissions(perms: readonly PermissionKey[]): PermissionKey[] {
|
|
100
|
+
const seen = new Set<string>();
|
|
101
|
+
const result: PermissionKey[] = [];
|
|
102
|
+
for (const perm of perms) {
|
|
103
|
+
const key = `${perm.resource}:${perm.action}`;
|
|
104
|
+
if (seen.has(key))
|
|
105
|
+
continue;
|
|
106
|
+
seen.add(key);
|
|
107
|
+
result.push(perm);
|
|
108
|
+
}
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Seed permissions from a set of endpoints and (optionally) bind them onto
|
|
114
|
+
* default roles. Safe to call repeatedly. See {@link PermissionSyncOptions}
|
|
115
|
+
* for shape.
|
|
116
|
+
*/
|
|
117
|
+
export async function runPermissionSync(
|
|
118
|
+
iam: IamService,
|
|
119
|
+
endpoints: EndpointDefinition[],
|
|
120
|
+
opts: PermissionSyncOptions = {},
|
|
121
|
+
): Promise<PermissionSyncResult> {
|
|
122
|
+
const unique = collectUniquePermissions(endpoints);
|
|
123
|
+
|
|
124
|
+
// Snapshot existing permissions so we can report created vs. existing.
|
|
125
|
+
// `findOrCreatePermission` upserts, so we can't tell after the fact.
|
|
126
|
+
const before: Permission[] = await iam.listPermissions();
|
|
127
|
+
const existingKeys = new Set(before.map((p: Permission) => `${p.resource}:${p.action}`));
|
|
128
|
+
|
|
129
|
+
for (const perm of unique)
|
|
130
|
+
await iam.createPermission(perm);
|
|
131
|
+
|
|
132
|
+
const existing = unique.filter(p => existingKeys.has(`${p.resource}:${p.action}`)).length;
|
|
133
|
+
const created = unique.length - existing;
|
|
134
|
+
|
|
135
|
+
const rolesReport: Record<string, { created: boolean; bound: number }> = {};
|
|
136
|
+
|
|
137
|
+
if (opts.defaultRoles) {
|
|
138
|
+
const allRoles = await iam.getRoles();
|
|
139
|
+
const rolesByName = new Map(allRoles.map(r => [r.name, r] as const));
|
|
140
|
+
|
|
141
|
+
for (const [roleName, spec] of Object.entries(opts.defaultRoles)) {
|
|
142
|
+
let permsToBind: PermissionKey[];
|
|
143
|
+
if (spec === '*') {
|
|
144
|
+
// `*` means every permission discovered from the supplied endpoint
|
|
145
|
+
// manifest, not every permission that happens to exist in the DB.
|
|
146
|
+
permsToBind = unique;
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
permsToBind = (spec as readonly string[]).map(parseRolePermSpec);
|
|
150
|
+
}
|
|
151
|
+
permsToBind = dedupePermissions(permsToBind);
|
|
152
|
+
|
|
153
|
+
const existingRole = rolesByName.get(roleName);
|
|
154
|
+
let roleCreated = false;
|
|
155
|
+
let roleId: string;
|
|
156
|
+
if (existingRole) {
|
|
157
|
+
roleId = existingRole.id;
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
// createRole both inserts the role and binds the supplied
|
|
161
|
+
// permissions in one call, so we can skip the explicit bind loop
|
|
162
|
+
// below for the create path.
|
|
163
|
+
const role = await iam.createRole(roleName, permsToBind);
|
|
164
|
+
roleId = role.id;
|
|
165
|
+
roleCreated = true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let bound = 0;
|
|
169
|
+
if (!roleCreated) {
|
|
170
|
+
const roleDetail = await iam.getRole(roleId);
|
|
171
|
+
const existingRolePerms = new Set(
|
|
172
|
+
roleDetail.permissions.map((p: Permission) => `${p.resource}:${p.action}`),
|
|
173
|
+
);
|
|
174
|
+
for (const perm of permsToBind) {
|
|
175
|
+
const key = `${perm.resource}:${perm.action}`;
|
|
176
|
+
if (existingRolePerms.has(key))
|
|
177
|
+
continue;
|
|
178
|
+
await iam.addPermissionToRole(roleId, perm);
|
|
179
|
+
existingRolePerms.add(key);
|
|
180
|
+
bound++;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
bound = permsToBind.length;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
rolesReport[roleName] = { created: roleCreated, bound };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { discovered: unique.length, existing, created, roles: rolesReport };
|
|
192
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { generateResourceTypes, parseResourceFile } from './resource-sync';
|
|
4
|
+
|
|
5
|
+
describe('parseResourceFile', () => {
|
|
6
|
+
it('accepts the canonical map shape and deduplicates actions', () => {
|
|
7
|
+
expect(parseResourceFile({
|
|
8
|
+
$schema: 'ignored annotation',
|
|
9
|
+
resources: { article: { actions: ['read', 'read'], description: 'Articles' } },
|
|
10
|
+
})).toEqual({
|
|
11
|
+
resources: { article: { actions: ['read'], description: 'Articles' } },
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it.each([
|
|
16
|
+
[null, 'must be an object'],
|
|
17
|
+
[{ resources: [] }, 'legacy resources array'],
|
|
18
|
+
[{ resources: { article: { actions: 'read' } } }, 'actions must be an array'],
|
|
19
|
+
[{ resources: { article: { actions: ['read', 1] } } }, 'actions must be an array'],
|
|
20
|
+
[{ resources: { article: { actions: [], description: 1 } } }, 'description must be a string'],
|
|
21
|
+
[JSON.parse('{"resources":{"__proto__":{"actions":["read"]}}}'), 'reserved or empty'],
|
|
22
|
+
])('rejects malformed resource documents', (value, message) => {
|
|
23
|
+
expect(() => parseResourceFile(value)).toThrow(message);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('generateResourceTypes', () => {
|
|
28
|
+
it('generates TypeScript types from resource definitions', () => {
|
|
29
|
+
const types = generateResourceTypes({
|
|
30
|
+
resources: {
|
|
31
|
+
user: { actions: ['create', 'read', 'update', 'delete'] },
|
|
32
|
+
post: { actions: ['create', 'read', 'publish'] },
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(types).toContain('"user"');
|
|
37
|
+
expect(types).toContain('"post"');
|
|
38
|
+
expect(types).toContain('"create" | "read" | "update" | "delete"');
|
|
39
|
+
expect(types).toContain('"create" | "read" | "publish"');
|
|
40
|
+
expect(types).toContain('FortressResource');
|
|
41
|
+
expect(types).toContain('FortressAction');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('handles empty resources', () => {
|
|
45
|
+
const types = generateResourceTypes({ resources: {} });
|
|
46
|
+
expect(types).toContain('never');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('escapes resource/action literals and handles empty action sets', () => {
|
|
50
|
+
const types = generateResourceTypes({
|
|
51
|
+
resources: {
|
|
52
|
+
'blog-post': { actions: [] },
|
|
53
|
+
'quoted': { actions: ['read\'quote'] },
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
expect(types).toContain('R extends "blog-post" ? never');
|
|
57
|
+
expect(types).toContain('"read\'quote"');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('handles single resource', () => {
|
|
61
|
+
const types = generateResourceTypes({
|
|
62
|
+
resources: {
|
|
63
|
+
invoice: { actions: ['create', 'void'] },
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(types).toContain('"invoice"');
|
|
68
|
+
expect(types).toContain('"create" | "void"');
|
|
69
|
+
});
|
|
70
|
+
});
|