@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,1612 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin plugin for fortress.
|
|
3
|
+
*
|
|
4
|
+
* Bootstraps an initial admin user and applies a default-deny policy to the
|
|
5
|
+
* IAM management endpoints, then exposes the full admin CRUD surface
|
|
6
|
+
* (users, groups, roles, permissions, role bindings) as HTTP routes guarded
|
|
7
|
+
* by fortress's own RBAC. Required if you want a self-service IAM admin UI
|
|
8
|
+
* out of the box.
|
|
9
|
+
*
|
|
10
|
+
* @module
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { EndpointDefinition, EndpointPermission } from '../../core/endpoint';
|
|
14
|
+
import type { ResourceFile } from '../../core/iam/resource-sync';
|
|
15
|
+
import type { FortressPlugin, PluginContext, PluginRouteContext } from '../../core/plugin';
|
|
16
|
+
import type { FortressUser, Group, Permission, PermissionInput, Role, SubjectType } from '../../core/types';
|
|
17
|
+
import type { ApiKeyInfo } from '../api-key/core';
|
|
18
|
+
import type { ApiKeyMethods } from '../api-key/index';
|
|
19
|
+
import { authEndpoints } from '../../core/auth/auth-endpoints';
|
|
20
|
+
import { Errors } from '../../core/errors';
|
|
21
|
+
import { iamEndpoints } from '../../core/iam/iam-endpoints';
|
|
22
|
+
import { pullResources } from '../../core/iam/resource-sync';
|
|
23
|
+
import { listKeysForSubject, revokeKeyAsAdmin } from '../api-key/core';
|
|
24
|
+
|
|
25
|
+
export interface AdminPluginOptions {
|
|
26
|
+
/** Resource name for fortress admin permissions. Default: 'fortress'. */
|
|
27
|
+
resource?: string;
|
|
28
|
+
/** Opt-in one-time bootstrap route. Disabled by default. */
|
|
29
|
+
bootstrap?: {
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
/** One-time secret. Defaults to process.env.FORTRESS_ADMIN_BOOTSTRAP_SECRET. */
|
|
32
|
+
secret?: string;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Mount admin-side api-key management routes under
|
|
36
|
+
* `/admin/users/:userId/api-keys/*` and
|
|
37
|
+
* `/admin/service-accounts/:id/api-keys/*`. Default `false`.
|
|
38
|
+
*
|
|
39
|
+
* When enabled, the admin plugin exposes POST, GET, and DELETE endpoints
|
|
40
|
+
* for any user's or service account's API keys, guarded by the
|
|
41
|
+
* `apiKey:manage` permission (auto-registered into the `fortress-admin`
|
|
42
|
+
* role via bootstrap). The POST endpoints are the only supported path
|
|
43
|
+
* for bootstrapping a service account's first key — a fresh service
|
|
44
|
+
* account has no credential of its own, so an admin must mint the
|
|
45
|
+
* initial key on its behalf. Requires the `api-key` plugin to also be
|
|
46
|
+
* registered, since the `api_key` model lives there.
|
|
47
|
+
*/
|
|
48
|
+
apiKeyRoutes?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Collect all unique permission declarations from endpoint definitions.
|
|
53
|
+
*/
|
|
54
|
+
function collectPermissions(endpoints: EndpointDefinition[]): EndpointPermission[] {
|
|
55
|
+
const seen = new Set<string>();
|
|
56
|
+
const result: EndpointPermission[] = [];
|
|
57
|
+
|
|
58
|
+
for (const ep of endpoints) {
|
|
59
|
+
const perm = ep.meta?.permission;
|
|
60
|
+
if (!perm)
|
|
61
|
+
continue;
|
|
62
|
+
const key = `${perm.resource}:${perm.action}`;
|
|
63
|
+
if (seen.has(key))
|
|
64
|
+
continue;
|
|
65
|
+
seen.add(key);
|
|
66
|
+
result.push(perm);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Inline JSON Schema helpers ────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
const userRef = { $ref: '#/components/schemas/User' };
|
|
75
|
+
const permissionRef = { $ref: '#/components/schemas/Permission' };
|
|
76
|
+
const permissionInputRef = { $ref: '#/components/schemas/PermissionInput' };
|
|
77
|
+
const errorRef = { $ref: '#/components/schemas/ErrorResponse' };
|
|
78
|
+
|
|
79
|
+
function intSchema(desc: string): { type: 'integer'; description: string } {
|
|
80
|
+
return { type: 'integer', description: desc };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function strSchema(desc: string): { type: 'string'; description: string } {
|
|
84
|
+
return { type: 'string', description: desc };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function boolSchema(desc: string): { type: 'boolean'; description: string } {
|
|
88
|
+
return { type: 'boolean', description: desc };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Parse a value to a required non-empty subject-id string.
|
|
93
|
+
*
|
|
94
|
+
* IDs are opaque strings at the fortress API surface (RFC 7519 §4.1.2 for
|
|
95
|
+
* JWT `sub`, and per the v0.3.0 lock-in audit). Numeric inputs are accepted
|
|
96
|
+
* and stringified — numeric-keyed adapters keep working transparently.
|
|
97
|
+
*/
|
|
98
|
+
const encoder = new TextEncoder();
|
|
99
|
+
|
|
100
|
+
function timingSafeEqual(a: string, b: string): boolean {
|
|
101
|
+
const left = encoder.encode(a);
|
|
102
|
+
const right = encoder.encode(b);
|
|
103
|
+
const len = Math.max(left.length, right.length);
|
|
104
|
+
let diff = left.length ^ right.length;
|
|
105
|
+
for (let i = 0; i < len; i++)
|
|
106
|
+
diff |= (left[i] ?? 0) ^ (right[i] ?? 0);
|
|
107
|
+
return diff === 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function requireId(v: unknown, name: string): string {
|
|
111
|
+
if (v == null || v === '')
|
|
112
|
+
throw Errors.badRequest(`${name} is required`);
|
|
113
|
+
if (typeof v === 'string')
|
|
114
|
+
return v;
|
|
115
|
+
if (typeof v === 'number' && Number.isFinite(v))
|
|
116
|
+
return String(v);
|
|
117
|
+
throw Errors.badRequest(`${name} must be a string or number`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Admin endpoint definitions ────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
const adminAuthEndpoints: EndpointDefinition[] = [
|
|
123
|
+
// GET /auth/users — list users (paginated, searchable)
|
|
124
|
+
{
|
|
125
|
+
method: 'GET',
|
|
126
|
+
path: '/auth/users',
|
|
127
|
+
handler: 'listUsers',
|
|
128
|
+
meta: {
|
|
129
|
+
summary: 'List users',
|
|
130
|
+
description: 'List all users with pagination and optional search',
|
|
131
|
+
tags: ['Auth', 'Admin'],
|
|
132
|
+
security: ['bearer'],
|
|
133
|
+
permission: { resource: 'fortress', action: 'viewUsers' },
|
|
134
|
+
},
|
|
135
|
+
input: {
|
|
136
|
+
query: {
|
|
137
|
+
type: 'object',
|
|
138
|
+
properties: {
|
|
139
|
+
limit: intSchema('Max results (default 50)'),
|
|
140
|
+
offset: intSchema('Skip results (default 0)'),
|
|
141
|
+
search: strSchema('Search by email'),
|
|
142
|
+
sortBy: strSchema('Sort field (default: id)'),
|
|
143
|
+
sortDirection: { type: 'string', enum: ['asc', 'desc'], description: 'Sort direction' },
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
responses: {
|
|
148
|
+
200: {
|
|
149
|
+
description: 'User list',
|
|
150
|
+
schema: {
|
|
151
|
+
type: 'object',
|
|
152
|
+
properties: {
|
|
153
|
+
users: { type: 'array', items: userRef, description: 'List of users' },
|
|
154
|
+
total: intSchema('Total number of users'),
|
|
155
|
+
},
|
|
156
|
+
required: ['users', 'total'],
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
401: { description: 'Not authenticated' },
|
|
160
|
+
403: { description: 'Insufficient permissions' },
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
// GET /auth/users/:id — get user by ID
|
|
165
|
+
{
|
|
166
|
+
method: 'GET',
|
|
167
|
+
path: '/auth/users/:id',
|
|
168
|
+
handler: 'getUserById',
|
|
169
|
+
meta: {
|
|
170
|
+
summary: 'Get user by ID',
|
|
171
|
+
tags: ['Auth', 'Admin'],
|
|
172
|
+
security: ['bearer'],
|
|
173
|
+
permission: { resource: 'fortress', action: 'viewUsers' },
|
|
174
|
+
},
|
|
175
|
+
input: {
|
|
176
|
+
params: {
|
|
177
|
+
type: 'object',
|
|
178
|
+
properties: { id: intSchema('User ID') },
|
|
179
|
+
required: ['id'],
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
responses: {
|
|
183
|
+
200: { description: 'User details', schema: userRef },
|
|
184
|
+
401: { description: 'Not authenticated' },
|
|
185
|
+
403: { description: 'Insufficient permissions' },
|
|
186
|
+
404: { description: 'User not found', schema: errorRef },
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
// PUT /auth/users/:id — update user
|
|
191
|
+
{
|
|
192
|
+
method: 'PUT',
|
|
193
|
+
path: '/auth/users/:id',
|
|
194
|
+
handler: 'updateUser',
|
|
195
|
+
meta: {
|
|
196
|
+
summary: 'Update user',
|
|
197
|
+
tags: ['Auth', 'Admin'],
|
|
198
|
+
security: ['bearer'],
|
|
199
|
+
permission: { resource: 'fortress', action: 'manageUsers' },
|
|
200
|
+
},
|
|
201
|
+
input: {
|
|
202
|
+
params: {
|
|
203
|
+
type: 'object',
|
|
204
|
+
properties: { id: intSchema('User ID') },
|
|
205
|
+
required: ['id'],
|
|
206
|
+
},
|
|
207
|
+
body: {
|
|
208
|
+
type: 'object',
|
|
209
|
+
properties: {
|
|
210
|
+
name: strSchema('Display name'),
|
|
211
|
+
email: { type: 'string', format: 'email', description: 'User email' },
|
|
212
|
+
isActive: boolSchema('Account active status'),
|
|
213
|
+
password: strSchema('New password (will be hashed)'),
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
responses: {
|
|
218
|
+
200: { description: 'Updated user', schema: userRef },
|
|
219
|
+
401: { description: 'Not authenticated' },
|
|
220
|
+
403: { description: 'Insufficient permissions' },
|
|
221
|
+
404: { description: 'User not found', schema: errorRef },
|
|
222
|
+
409: { description: 'Email already in use', schema: errorRef },
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
// DELETE /auth/users/:id — delete user
|
|
227
|
+
{
|
|
228
|
+
method: 'DELETE',
|
|
229
|
+
path: '/auth/users/:id',
|
|
230
|
+
handler: 'deleteUser',
|
|
231
|
+
meta: {
|
|
232
|
+
summary: 'Delete user',
|
|
233
|
+
tags: ['Auth', 'Admin'],
|
|
234
|
+
security: ['bearer'],
|
|
235
|
+
permission: { resource: 'fortress', action: 'manageUsers' },
|
|
236
|
+
},
|
|
237
|
+
input: {
|
|
238
|
+
params: {
|
|
239
|
+
type: 'object',
|
|
240
|
+
properties: { id: intSchema('User ID') },
|
|
241
|
+
required: ['id'],
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
responses: {
|
|
245
|
+
200: { description: 'User deleted', schema: { type: 'object', properties: { ok: boolSchema('Success') } } },
|
|
246
|
+
401: { description: 'Not authenticated' },
|
|
247
|
+
403: { description: 'Insufficient permissions' },
|
|
248
|
+
404: { description: 'User not found', schema: errorRef },
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
// POST /auth/users — admin-initiated user creation
|
|
253
|
+
{
|
|
254
|
+
method: 'POST',
|
|
255
|
+
path: '/auth/users',
|
|
256
|
+
handler: 'createUser',
|
|
257
|
+
meta: {
|
|
258
|
+
summary: 'Create a user (admin)',
|
|
259
|
+
description: 'Admin-initiated user creation. Unlike /auth/register, requires authentication and manageUsers permission.',
|
|
260
|
+
tags: ['Auth', 'Admin'],
|
|
261
|
+
security: ['bearer'],
|
|
262
|
+
permission: { resource: 'fortress', action: 'manageUsers' },
|
|
263
|
+
},
|
|
264
|
+
input: {
|
|
265
|
+
body: {
|
|
266
|
+
type: 'object',
|
|
267
|
+
properties: {
|
|
268
|
+
email: { type: 'string', format: 'email', description: 'User email' },
|
|
269
|
+
name: strSchema('Display name'),
|
|
270
|
+
password: strSchema('User password'),
|
|
271
|
+
isActive: boolSchema('Set active status (default true)'),
|
|
272
|
+
},
|
|
273
|
+
required: ['email', 'name'],
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
responses: {
|
|
277
|
+
201: { description: 'User created', schema: userRef },
|
|
278
|
+
401: { description: 'Not authenticated' },
|
|
279
|
+
403: { description: 'Insufficient permissions' },
|
|
280
|
+
409: { description: 'Email already exists', schema: errorRef },
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
];
|
|
284
|
+
|
|
285
|
+
const adminIamEndpoints: EndpointDefinition[] = [
|
|
286
|
+
// GET /iam/roles/:id — get role with permissions
|
|
287
|
+
{
|
|
288
|
+
method: 'GET',
|
|
289
|
+
path: '/iam/roles/:id',
|
|
290
|
+
handler: 'getRole',
|
|
291
|
+
meta: {
|
|
292
|
+
summary: 'Get role with permissions',
|
|
293
|
+
tags: ['IAM', 'Admin'],
|
|
294
|
+
security: ['bearer'],
|
|
295
|
+
permission: { resource: 'fortress', action: 'viewRoles' },
|
|
296
|
+
},
|
|
297
|
+
input: {
|
|
298
|
+
params: {
|
|
299
|
+
type: 'object',
|
|
300
|
+
properties: { id: intSchema('Role ID') },
|
|
301
|
+
required: ['id'],
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
responses: {
|
|
305
|
+
200: {
|
|
306
|
+
description: 'Role with permissions',
|
|
307
|
+
schema: {
|
|
308
|
+
type: 'object',
|
|
309
|
+
properties: {
|
|
310
|
+
id: intSchema('Role ID'),
|
|
311
|
+
name: strSchema('Role name'),
|
|
312
|
+
description: { type: 'string', nullable: true, description: 'Role description' },
|
|
313
|
+
isSystem: boolSchema('Whether this is a system role'),
|
|
314
|
+
permissions: { type: 'array', items: permissionRef, description: 'Role permissions' },
|
|
315
|
+
},
|
|
316
|
+
required: ['id', 'name', 'permissions'],
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
404: { description: 'Role not found', schema: errorRef },
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
// PUT /iam/roles/:id — update role
|
|
324
|
+
{
|
|
325
|
+
method: 'PUT',
|
|
326
|
+
path: '/iam/roles/:id',
|
|
327
|
+
handler: 'updateRole',
|
|
328
|
+
meta: {
|
|
329
|
+
summary: 'Update role',
|
|
330
|
+
tags: ['IAM', 'Admin'],
|
|
331
|
+
security: ['bearer'],
|
|
332
|
+
permission: { resource: 'fortress', action: 'manageRoles' },
|
|
333
|
+
},
|
|
334
|
+
input: {
|
|
335
|
+
params: {
|
|
336
|
+
type: 'object',
|
|
337
|
+
properties: { id: intSchema('Role ID') },
|
|
338
|
+
required: ['id'],
|
|
339
|
+
},
|
|
340
|
+
body: {
|
|
341
|
+
type: 'object',
|
|
342
|
+
properties: {
|
|
343
|
+
name: strSchema('Role name'),
|
|
344
|
+
description: strSchema('Role description'),
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
responses: {
|
|
349
|
+
200: { description: 'Updated role', schema: { $ref: '#/components/schemas/Role' } },
|
|
350
|
+
400: { description: 'Cannot update system role', schema: errorRef },
|
|
351
|
+
404: { description: 'Role not found', schema: errorRef },
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
|
|
355
|
+
// GET /iam/groups — list groups (paginated)
|
|
356
|
+
{
|
|
357
|
+
method: 'GET',
|
|
358
|
+
path: '/iam/groups',
|
|
359
|
+
handler: 'listGroups',
|
|
360
|
+
meta: {
|
|
361
|
+
summary: 'List groups',
|
|
362
|
+
tags: ['IAM', 'Admin'],
|
|
363
|
+
security: ['bearer'],
|
|
364
|
+
permission: { resource: 'fortress', action: 'viewGroups' },
|
|
365
|
+
},
|
|
366
|
+
input: {
|
|
367
|
+
query: {
|
|
368
|
+
type: 'object',
|
|
369
|
+
properties: {
|
|
370
|
+
limit: intSchema('Max results (default 50)'),
|
|
371
|
+
offset: intSchema('Skip results (default 0)'),
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
responses: {
|
|
376
|
+
200: {
|
|
377
|
+
description: 'Group list',
|
|
378
|
+
schema: {
|
|
379
|
+
type: 'object',
|
|
380
|
+
properties: {
|
|
381
|
+
groups: { type: 'array', items: { $ref: '#/components/schemas/Group' }, description: 'List of groups' },
|
|
382
|
+
total: intSchema('Total number of groups'),
|
|
383
|
+
},
|
|
384
|
+
required: ['groups', 'total'],
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
|
|
390
|
+
// GET /iam/groups/:id — get group with members
|
|
391
|
+
{
|
|
392
|
+
method: 'GET',
|
|
393
|
+
path: '/iam/groups/:id',
|
|
394
|
+
handler: 'getGroup',
|
|
395
|
+
meta: {
|
|
396
|
+
summary: 'Get group with members',
|
|
397
|
+
tags: ['IAM', 'Admin'],
|
|
398
|
+
security: ['bearer'],
|
|
399
|
+
permission: { resource: 'fortress', action: 'viewGroups' },
|
|
400
|
+
},
|
|
401
|
+
input: {
|
|
402
|
+
params: {
|
|
403
|
+
type: 'object',
|
|
404
|
+
properties: { id: intSchema('Group ID') },
|
|
405
|
+
required: ['id'],
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
responses: {
|
|
409
|
+
200: {
|
|
410
|
+
description: 'Group with members',
|
|
411
|
+
schema: {
|
|
412
|
+
type: 'object',
|
|
413
|
+
properties: {
|
|
414
|
+
id: intSchema('Group ID'),
|
|
415
|
+
name: strSchema('Group name'),
|
|
416
|
+
description: { type: 'string', nullable: true, description: 'Group description' },
|
|
417
|
+
users: { type: 'array', items: userRef, description: 'Group members' },
|
|
418
|
+
},
|
|
419
|
+
required: ['id', 'name', 'users'],
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
404: { description: 'Group not found', schema: errorRef },
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
|
|
426
|
+
// PUT /iam/groups/:id — update group
|
|
427
|
+
{
|
|
428
|
+
method: 'PUT',
|
|
429
|
+
path: '/iam/groups/:id',
|
|
430
|
+
handler: 'updateGroup',
|
|
431
|
+
meta: {
|
|
432
|
+
summary: 'Update group',
|
|
433
|
+
tags: ['IAM', 'Admin'],
|
|
434
|
+
security: ['bearer'],
|
|
435
|
+
permission: { resource: 'fortress', action: 'manageGroup' },
|
|
436
|
+
},
|
|
437
|
+
input: {
|
|
438
|
+
params: {
|
|
439
|
+
type: 'object',
|
|
440
|
+
properties: { id: intSchema('Group ID') },
|
|
441
|
+
required: ['id'],
|
|
442
|
+
},
|
|
443
|
+
body: {
|
|
444
|
+
type: 'object',
|
|
445
|
+
properties: {
|
|
446
|
+
name: strSchema('Group name'),
|
|
447
|
+
description: strSchema('Group description'),
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
},
|
|
451
|
+
responses: {
|
|
452
|
+
200: { description: 'Updated group', schema: { $ref: '#/components/schemas/Group' } },
|
|
453
|
+
404: { description: 'Group not found', schema: errorRef },
|
|
454
|
+
},
|
|
455
|
+
},
|
|
456
|
+
|
|
457
|
+
// DELETE /iam/groups/:id — delete group
|
|
458
|
+
{
|
|
459
|
+
method: 'DELETE',
|
|
460
|
+
path: '/iam/groups/:id',
|
|
461
|
+
handler: 'deleteGroup',
|
|
462
|
+
meta: {
|
|
463
|
+
summary: 'Delete group',
|
|
464
|
+
tags: ['IAM', 'Admin'],
|
|
465
|
+
security: ['bearer'],
|
|
466
|
+
permission: { resource: 'fortress', action: 'manageGroup' },
|
|
467
|
+
},
|
|
468
|
+
input: {
|
|
469
|
+
params: {
|
|
470
|
+
type: 'object',
|
|
471
|
+
properties: { id: intSchema('Group ID') },
|
|
472
|
+
required: ['id'],
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
responses: {
|
|
476
|
+
200: { description: 'Group deleted', schema: { type: 'object', properties: { ok: boolSchema('Success') } } },
|
|
477
|
+
404: { description: 'Group not found', schema: errorRef },
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
|
|
481
|
+
// GET /iam/groups/:id/users — list group members
|
|
482
|
+
{
|
|
483
|
+
method: 'GET',
|
|
484
|
+
path: '/iam/groups/:id/users',
|
|
485
|
+
handler: 'getGroupUsers',
|
|
486
|
+
meta: {
|
|
487
|
+
summary: 'List group members',
|
|
488
|
+
tags: ['IAM', 'Admin'],
|
|
489
|
+
security: ['bearer'],
|
|
490
|
+
permission: { resource: 'fortress', action: 'viewGroups' },
|
|
491
|
+
},
|
|
492
|
+
input: {
|
|
493
|
+
params: {
|
|
494
|
+
type: 'object',
|
|
495
|
+
properties: { id: intSchema('Group ID') },
|
|
496
|
+
required: ['id'],
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
responses: {
|
|
500
|
+
200: {
|
|
501
|
+
description: 'Group members',
|
|
502
|
+
schema: { type: 'array', items: userRef },
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
},
|
|
506
|
+
|
|
507
|
+
// GET /iam/permissions — list all permissions
|
|
508
|
+
{
|
|
509
|
+
method: 'GET',
|
|
510
|
+
path: '/iam/permissions',
|
|
511
|
+
handler: 'listPermissions',
|
|
512
|
+
meta: {
|
|
513
|
+
summary: 'List permissions',
|
|
514
|
+
tags: ['IAM', 'Admin'],
|
|
515
|
+
security: ['bearer'],
|
|
516
|
+
permission: { resource: 'fortress', action: 'viewPermissions' },
|
|
517
|
+
},
|
|
518
|
+
input: {
|
|
519
|
+
query: {
|
|
520
|
+
type: 'object',
|
|
521
|
+
properties: {
|
|
522
|
+
resource: strSchema('Filter by resource name'),
|
|
523
|
+
},
|
|
524
|
+
},
|
|
525
|
+
},
|
|
526
|
+
responses: {
|
|
527
|
+
200: {
|
|
528
|
+
description: 'Permission list',
|
|
529
|
+
schema: { type: 'array', items: permissionRef },
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
|
|
534
|
+
// POST /iam/permissions — create permission
|
|
535
|
+
{
|
|
536
|
+
method: 'POST',
|
|
537
|
+
path: '/iam/permissions',
|
|
538
|
+
handler: 'createPermission',
|
|
539
|
+
meta: {
|
|
540
|
+
summary: 'Create permission',
|
|
541
|
+
tags: ['IAM', 'Admin'],
|
|
542
|
+
security: ['bearer'],
|
|
543
|
+
permission: { resource: 'fortress', action: 'managePermissions' },
|
|
544
|
+
},
|
|
545
|
+
input: {
|
|
546
|
+
body: permissionInputRef,
|
|
547
|
+
},
|
|
548
|
+
responses: {
|
|
549
|
+
200: { description: 'Created permission', schema: permissionRef },
|
|
550
|
+
},
|
|
551
|
+
},
|
|
552
|
+
|
|
553
|
+
// DELETE /iam/permissions/:id — delete permission
|
|
554
|
+
{
|
|
555
|
+
method: 'DELETE',
|
|
556
|
+
path: '/iam/permissions/:id',
|
|
557
|
+
handler: 'deletePermission',
|
|
558
|
+
meta: {
|
|
559
|
+
summary: 'Delete permission',
|
|
560
|
+
tags: ['IAM', 'Admin'],
|
|
561
|
+
security: ['bearer'],
|
|
562
|
+
permission: { resource: 'fortress', action: 'managePermissions' },
|
|
563
|
+
},
|
|
564
|
+
input: {
|
|
565
|
+
params: {
|
|
566
|
+
type: 'object',
|
|
567
|
+
properties: { id: intSchema('Permission ID') },
|
|
568
|
+
required: ['id'],
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
responses: {
|
|
572
|
+
200: { description: 'Permission deleted', schema: { type: 'object', properties: { ok: boolSchema('Success') } } },
|
|
573
|
+
404: { description: 'Permission not found', schema: errorRef },
|
|
574
|
+
},
|
|
575
|
+
},
|
|
576
|
+
|
|
577
|
+
// POST /iam/roles/:id/permissions — add permission to role
|
|
578
|
+
{
|
|
579
|
+
method: 'POST',
|
|
580
|
+
path: '/iam/roles/:id/permissions',
|
|
581
|
+
handler: 'addPermissionToRole',
|
|
582
|
+
meta: {
|
|
583
|
+
summary: 'Add permission to role',
|
|
584
|
+
tags: ['IAM', 'Admin'],
|
|
585
|
+
security: ['bearer'],
|
|
586
|
+
permission: { resource: 'fortress', action: 'manageRoles' },
|
|
587
|
+
},
|
|
588
|
+
input: {
|
|
589
|
+
params: {
|
|
590
|
+
type: 'object',
|
|
591
|
+
properties: { id: intSchema('Role ID') },
|
|
592
|
+
required: ['id'],
|
|
593
|
+
},
|
|
594
|
+
body: permissionInputRef,
|
|
595
|
+
},
|
|
596
|
+
responses: {
|
|
597
|
+
200: { description: 'Permission added', schema: { type: 'object', properties: { ok: boolSchema('Success') } } },
|
|
598
|
+
404: { description: 'Role not found', schema: errorRef },
|
|
599
|
+
},
|
|
600
|
+
},
|
|
601
|
+
];
|
|
602
|
+
|
|
603
|
+
// ── Sync endpoint ────────────────────────────────────────────────
|
|
604
|
+
|
|
605
|
+
const syncEndpoint: EndpointDefinition = {
|
|
606
|
+
method: 'POST',
|
|
607
|
+
path: '/iam/sync',
|
|
608
|
+
handler: 'syncResources',
|
|
609
|
+
meta: {
|
|
610
|
+
summary: 'Sync IAM resources',
|
|
611
|
+
description: 'Push or pull resource definitions to/from the resource file',
|
|
612
|
+
tags: ['IAM', 'Admin'],
|
|
613
|
+
security: ['bearer'],
|
|
614
|
+
permission: { resource: 'fortress', action: 'managePermissions' },
|
|
615
|
+
},
|
|
616
|
+
input: {
|
|
617
|
+
body: {
|
|
618
|
+
type: 'object',
|
|
619
|
+
properties: {
|
|
620
|
+
direction: { type: 'string', enum: ['push', 'pull'], description: 'Sync direction' },
|
|
621
|
+
filePath: strSchema('Resource file path (optional)'),
|
|
622
|
+
},
|
|
623
|
+
required: ['direction'],
|
|
624
|
+
},
|
|
625
|
+
},
|
|
626
|
+
responses: {
|
|
627
|
+
200: { description: 'Sync complete', schema: { type: 'object', properties: { ok: boolSchema('Success') } } },
|
|
628
|
+
401: { description: 'Not authenticated' },
|
|
629
|
+
403: { description: 'Insufficient permissions' },
|
|
630
|
+
},
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
// ── Bootstrap endpoint ────────────────────────────────────────────
|
|
634
|
+
|
|
635
|
+
const bootstrapEndpoint: EndpointDefinition = {
|
|
636
|
+
method: 'POST',
|
|
637
|
+
path: '/iam/admin/bootstrap',
|
|
638
|
+
handler: 'bootstrap',
|
|
639
|
+
meta: {
|
|
640
|
+
summary: 'Bootstrap admin user',
|
|
641
|
+
description: 'Opt-in emergency bootstrap. Assigns all fortress admin permissions only while zero fortress-admin bindings exist and only when the caller presents the one-time bootstrap secret. Creates the fortress resource and permissions if they do not exist.',
|
|
642
|
+
tags: ['IAM', 'Admin'],
|
|
643
|
+
security: ['bearer'],
|
|
644
|
+
},
|
|
645
|
+
input: {
|
|
646
|
+
body: {
|
|
647
|
+
type: 'object',
|
|
648
|
+
properties: {
|
|
649
|
+
userId: { type: 'integer', description: 'Target user ID. Defaults to the authenticated caller.' },
|
|
650
|
+
secret: { type: 'string', description: 'One-time bootstrap secret' },
|
|
651
|
+
},
|
|
652
|
+
},
|
|
653
|
+
},
|
|
654
|
+
responses: {
|
|
655
|
+
200: { description: 'Admin bootstrapped', schema: { type: 'object', properties: { ok: { type: 'boolean' }, role: { type: 'object' } } } },
|
|
656
|
+
401: { description: 'Not authenticated' },
|
|
657
|
+
403: { description: 'Bootstrap disabled, bad secret, or already bootstrapped' },
|
|
658
|
+
},
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
// ── Admin-side api-key management endpoints ─────────────────────────
|
|
662
|
+
//
|
|
663
|
+
// Mounted only when `admin({ apiKeyRoutes: true })`. Guarded by the
|
|
664
|
+
// `apiKey:manage` permission (auto-discovered into the `fortress-admin` role
|
|
665
|
+
// by `bootstrap` when these routes are present). Requires the `api-key`
|
|
666
|
+
// plugin to be registered alongside admin, since the `api_key` model lives
|
|
667
|
+
// there.
|
|
668
|
+
|
|
669
|
+
const apiKeyInfoSchema = {
|
|
670
|
+
type: 'object' as const,
|
|
671
|
+
properties: {
|
|
672
|
+
id: intSchema('Database id'),
|
|
673
|
+
name: strSchema('Key label'),
|
|
674
|
+
keyPrefix: strSchema('First 12 characters of the key, for identification'),
|
|
675
|
+
scopes: { type: 'array' as const, items: { type: 'string' as const }, nullable: true },
|
|
676
|
+
expiresAt: { type: 'string' as const, format: 'date-time', nullable: true },
|
|
677
|
+
lastUsedAt: { type: 'string' as const, format: 'date-time', nullable: true },
|
|
678
|
+
createdAt: { type: 'string' as const, format: 'date-time' },
|
|
679
|
+
},
|
|
680
|
+
required: ['id', 'name', 'keyPrefix', 'createdAt'] as string[],
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
const createKeyBodySchema = {
|
|
684
|
+
type: 'object' as const,
|
|
685
|
+
properties: {
|
|
686
|
+
name: strSchema('Human-readable key label'),
|
|
687
|
+
scopes: { type: 'array' as const, items: { type: 'string' as const }, description: 'Optional permission scopes attached to the key' },
|
|
688
|
+
expiresAt: { type: 'string' as const, description: 'Optional expiry (ISO 8601 string)' },
|
|
689
|
+
},
|
|
690
|
+
required: ['name'] as string[],
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
const createKeyResponseSchema = {
|
|
694
|
+
type: 'object' as const,
|
|
695
|
+
properties: {
|
|
696
|
+
key: strSchema('Raw API key — shown exactly once, store it immediately'),
|
|
697
|
+
id: intSchema('Database id of the key'),
|
|
698
|
+
},
|
|
699
|
+
required: ['key', 'id'] as string[],
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
const adminApiKeyEndpoints: EndpointDefinition[] = [
|
|
703
|
+
// ── User-scoped admin api-key management ──────────────────────
|
|
704
|
+
|
|
705
|
+
{
|
|
706
|
+
method: 'POST',
|
|
707
|
+
path: '/admin/users/:userId/api-keys',
|
|
708
|
+
handler: 'adminCreateUserApiKey',
|
|
709
|
+
meta: {
|
|
710
|
+
summary: 'Mint an API key for any user',
|
|
711
|
+
description: 'Create a new API key owned by any user, bypassing the self-service `maxKeysPerSubject` check is NOT done — the same limit applies. The raw key is returned exactly once and cannot be retrieved later. Requires the `apiKey:manage` permission. Typical use: provisioning keys during tenant onboarding or replacing a lost key on a user\'s behalf.',
|
|
712
|
+
tags: ['Admin', 'API Keys'],
|
|
713
|
+
security: ['bearer'],
|
|
714
|
+
permission: { resource: 'apiKey', action: 'manage' },
|
|
715
|
+
},
|
|
716
|
+
input: {
|
|
717
|
+
params: {
|
|
718
|
+
type: 'object',
|
|
719
|
+
properties: { userId: intSchema('Target user ID') },
|
|
720
|
+
required: ['userId'],
|
|
721
|
+
},
|
|
722
|
+
body: createKeyBodySchema,
|
|
723
|
+
},
|
|
724
|
+
responses: {
|
|
725
|
+
201: { description: 'Key created', schema: createKeyResponseSchema },
|
|
726
|
+
400: { description: 'Bad request', schema: errorRef },
|
|
727
|
+
401: { description: 'Not authenticated', schema: errorRef },
|
|
728
|
+
403: { description: 'Forbidden', schema: errorRef },
|
|
729
|
+
404: { description: 'User not found', schema: errorRef },
|
|
730
|
+
},
|
|
731
|
+
},
|
|
732
|
+
|
|
733
|
+
{
|
|
734
|
+
method: 'GET',
|
|
735
|
+
path: '/admin/users/:userId/api-keys',
|
|
736
|
+
handler: 'adminListUserApiKeys',
|
|
737
|
+
meta: {
|
|
738
|
+
summary: 'List a user\'s API keys',
|
|
739
|
+
description: 'Return the active (non-revoked) API keys belonging to any user. Raw keys and hashes are never returned. Requires the `apiKey:manage` permission.',
|
|
740
|
+
tags: ['Admin', 'API Keys'],
|
|
741
|
+
security: ['bearer'],
|
|
742
|
+
permission: { resource: 'apiKey', action: 'manage' },
|
|
743
|
+
},
|
|
744
|
+
input: {
|
|
745
|
+
params: {
|
|
746
|
+
type: 'object',
|
|
747
|
+
properties: { userId: intSchema('Target user ID') },
|
|
748
|
+
required: ['userId'],
|
|
749
|
+
},
|
|
750
|
+
},
|
|
751
|
+
responses: {
|
|
752
|
+
200: {
|
|
753
|
+
description: 'Keys',
|
|
754
|
+
schema: {
|
|
755
|
+
type: 'object',
|
|
756
|
+
properties: {
|
|
757
|
+
keys: { type: 'array', items: apiKeyInfoSchema },
|
|
758
|
+
},
|
|
759
|
+
required: ['keys'],
|
|
760
|
+
},
|
|
761
|
+
},
|
|
762
|
+
401: { description: 'Not authenticated', schema: errorRef },
|
|
763
|
+
403: { description: 'Forbidden', schema: errorRef },
|
|
764
|
+
},
|
|
765
|
+
},
|
|
766
|
+
|
|
767
|
+
{
|
|
768
|
+
method: 'DELETE',
|
|
769
|
+
path: '/admin/users/:userId/api-keys/:id',
|
|
770
|
+
handler: 'adminRevokeUserApiKey',
|
|
771
|
+
meta: {
|
|
772
|
+
summary: 'Revoke a user\'s API key',
|
|
773
|
+
description: 'Revoke any API key by id, bypassing the self-service ownership check. Requires the `apiKey:manage` permission. Typically used to respond to leaked keys or compromised accounts.',
|
|
774
|
+
tags: ['Admin', 'API Keys'],
|
|
775
|
+
security: ['bearer'],
|
|
776
|
+
permission: { resource: 'apiKey', action: 'manage' },
|
|
777
|
+
},
|
|
778
|
+
input: {
|
|
779
|
+
params: {
|
|
780
|
+
type: 'object',
|
|
781
|
+
properties: {
|
|
782
|
+
userId: intSchema('Target user ID (for URL namespacing; ownership is not enforced)'),
|
|
783
|
+
id: intSchema('Key id to revoke'),
|
|
784
|
+
},
|
|
785
|
+
required: ['userId', 'id'],
|
|
786
|
+
},
|
|
787
|
+
},
|
|
788
|
+
responses: {
|
|
789
|
+
200: {
|
|
790
|
+
description: 'Revoked',
|
|
791
|
+
schema: {
|
|
792
|
+
type: 'object',
|
|
793
|
+
properties: { ok: boolSchema('Success') },
|
|
794
|
+
required: ['ok'],
|
|
795
|
+
},
|
|
796
|
+
},
|
|
797
|
+
401: { description: 'Not authenticated', schema: errorRef },
|
|
798
|
+
403: { description: 'Forbidden', schema: errorRef },
|
|
799
|
+
404: { description: 'Not found', schema: errorRef },
|
|
800
|
+
},
|
|
801
|
+
},
|
|
802
|
+
|
|
803
|
+
// ── Service-account-scoped admin api-key management ───────────
|
|
804
|
+
//
|
|
805
|
+
// Service accounts have no login path — they can't self-mint their
|
|
806
|
+
// first key. These endpoints are the only supported way to bootstrap
|
|
807
|
+
// a service account's credentials.
|
|
808
|
+
|
|
809
|
+
{
|
|
810
|
+
method: 'POST',
|
|
811
|
+
path: '/admin/service-accounts/:id/api-keys',
|
|
812
|
+
handler: 'adminCreateServiceAccountApiKey',
|
|
813
|
+
meta: {
|
|
814
|
+
summary: 'Mint an API key for a service account',
|
|
815
|
+
description: 'Create a new API key owned by a service account. This is the primary entry point for bootstrapping a service account\'s credentials — a fresh service account has no way to authenticate until an admin mints its first key. The raw key is returned exactly once and cannot be retrieved later. Requires the `apiKey:manage` permission.',
|
|
816
|
+
tags: ['Admin', 'API Keys', 'Service Accounts'],
|
|
817
|
+
security: ['bearer'],
|
|
818
|
+
permission: { resource: 'apiKey', action: 'manage' },
|
|
819
|
+
},
|
|
820
|
+
input: {
|
|
821
|
+
params: {
|
|
822
|
+
type: 'object',
|
|
823
|
+
properties: { id: intSchema('Target service account ID') },
|
|
824
|
+
required: ['id'],
|
|
825
|
+
},
|
|
826
|
+
body: createKeyBodySchema,
|
|
827
|
+
},
|
|
828
|
+
responses: {
|
|
829
|
+
201: { description: 'Key created', schema: createKeyResponseSchema },
|
|
830
|
+
400: { description: 'Bad request', schema: errorRef },
|
|
831
|
+
401: { description: 'Not authenticated', schema: errorRef },
|
|
832
|
+
403: { description: 'Forbidden', schema: errorRef },
|
|
833
|
+
404: { description: 'Service account not found', schema: errorRef },
|
|
834
|
+
},
|
|
835
|
+
},
|
|
836
|
+
|
|
837
|
+
{
|
|
838
|
+
method: 'GET',
|
|
839
|
+
path: '/admin/service-accounts/:id/api-keys',
|
|
840
|
+
handler: 'adminListServiceAccountApiKeys',
|
|
841
|
+
meta: {
|
|
842
|
+
summary: 'List a service account\'s API keys',
|
|
843
|
+
description: 'Return the active (non-revoked) API keys owned by a service account. Raw keys and hashes are never returned. Requires the `apiKey:manage` permission.',
|
|
844
|
+
tags: ['Admin', 'API Keys', 'Service Accounts'],
|
|
845
|
+
security: ['bearer'],
|
|
846
|
+
permission: { resource: 'apiKey', action: 'manage' },
|
|
847
|
+
},
|
|
848
|
+
input: {
|
|
849
|
+
params: {
|
|
850
|
+
type: 'object',
|
|
851
|
+
properties: { id: intSchema('Target service account ID') },
|
|
852
|
+
required: ['id'],
|
|
853
|
+
},
|
|
854
|
+
},
|
|
855
|
+
responses: {
|
|
856
|
+
200: {
|
|
857
|
+
description: 'Keys',
|
|
858
|
+
schema: {
|
|
859
|
+
type: 'object',
|
|
860
|
+
properties: {
|
|
861
|
+
keys: { type: 'array', items: apiKeyInfoSchema },
|
|
862
|
+
},
|
|
863
|
+
required: ['keys'],
|
|
864
|
+
},
|
|
865
|
+
},
|
|
866
|
+
401: { description: 'Not authenticated', schema: errorRef },
|
|
867
|
+
403: { description: 'Forbidden', schema: errorRef },
|
|
868
|
+
},
|
|
869
|
+
},
|
|
870
|
+
|
|
871
|
+
{
|
|
872
|
+
method: 'DELETE',
|
|
873
|
+
path: '/admin/service-accounts/:id/api-keys/:keyId',
|
|
874
|
+
handler: 'adminRevokeServiceAccountApiKey',
|
|
875
|
+
meta: {
|
|
876
|
+
summary: 'Revoke a service account\'s API key',
|
|
877
|
+
description: 'Revoke any API key by id, bypassing the self-service ownership check. Requires the `apiKey:manage` permission.',
|
|
878
|
+
tags: ['Admin', 'API Keys', 'Service Accounts'],
|
|
879
|
+
security: ['bearer'],
|
|
880
|
+
permission: { resource: 'apiKey', action: 'manage' },
|
|
881
|
+
},
|
|
882
|
+
input: {
|
|
883
|
+
params: {
|
|
884
|
+
type: 'object',
|
|
885
|
+
properties: {
|
|
886
|
+
id: intSchema('Target service account ID (for URL namespacing; ownership is not enforced)'),
|
|
887
|
+
keyId: intSchema('Key id to revoke'),
|
|
888
|
+
},
|
|
889
|
+
required: ['id', 'keyId'],
|
|
890
|
+
},
|
|
891
|
+
},
|
|
892
|
+
responses: {
|
|
893
|
+
200: {
|
|
894
|
+
description: 'Revoked',
|
|
895
|
+
schema: {
|
|
896
|
+
type: 'object',
|
|
897
|
+
properties: { ok: boolSchema('Success') },
|
|
898
|
+
required: ['ok'],
|
|
899
|
+
},
|
|
900
|
+
},
|
|
901
|
+
401: { description: 'Not authenticated', schema: errorRef },
|
|
902
|
+
403: { description: 'Forbidden', schema: errorRef },
|
|
903
|
+
404: { description: 'Not found', schema: errorRef },
|
|
904
|
+
},
|
|
905
|
+
},
|
|
906
|
+
];
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Admin plugin — protects fortress routes and provides admin CRUD endpoints.
|
|
910
|
+
*
|
|
911
|
+
* Adds endpoints for:
|
|
912
|
+
* - User management (list, get, update, delete)
|
|
913
|
+
* - Role management (get, update, add permissions)
|
|
914
|
+
* - Group management (list, get, update, delete, members)
|
|
915
|
+
* - Permission management (list, create, delete)
|
|
916
|
+
* - Admin bootstrap
|
|
917
|
+
*
|
|
918
|
+
* Works with the RBAC middleware's security-aware default deny:
|
|
919
|
+
* - All endpoints declare `permission: { resource: 'fortress', action: '...' }`
|
|
920
|
+
* - RBAC middleware enforces these automatically
|
|
921
|
+
* - Admin plugin adds opt-in one-time-secret bootstrap functionality
|
|
922
|
+
*
|
|
923
|
+
* @example
|
|
924
|
+
* ```ts
|
|
925
|
+
* import { admin } from '@bajustone/fortress/plugins/admin';
|
|
926
|
+
*
|
|
927
|
+
* const fortress = createFortress({
|
|
928
|
+
* plugins: [
|
|
929
|
+
* admin({ bootstrap: { enabled: true, secret: process.env.FORTRESS_ADMIN_BOOTSTRAP_SECRET } }),
|
|
930
|
+
* ],
|
|
931
|
+
* });
|
|
932
|
+
* ```
|
|
933
|
+
*/
|
|
934
|
+
export function admin(options: AdminPluginOptions = {}): FortressPlugin {
|
|
935
|
+
const mountApiKeyRoutes = options.apiKeyRoutes === true;
|
|
936
|
+
const mountBootstrap = options.bootstrap?.enabled === true;
|
|
937
|
+
const bootstrapSecret = options.bootstrap?.secret ?? process.env.FORTRESS_ADMIN_BOOTSTRAP_SECRET;
|
|
938
|
+
|
|
939
|
+
return {
|
|
940
|
+
name: 'admin',
|
|
941
|
+
|
|
942
|
+
// Admin routes are aggregated from several internal arrays into a
|
|
943
|
+
// record keyed by `${method}_${path}` so collisions with core handler
|
|
944
|
+
// names (e.g. `createUser` exists in both auth-endpoints.ts and admin)
|
|
945
|
+
// are impossible. Admin routes exist for default-deny protection and
|
|
946
|
+
// aren't exposed via the typed `fortress.call.*` surface.
|
|
947
|
+
routes: Object.fromEntries(
|
|
948
|
+
[
|
|
949
|
+
...(mountBootstrap ? [bootstrapEndpoint] : []),
|
|
950
|
+
syncEndpoint,
|
|
951
|
+
...adminAuthEndpoints,
|
|
952
|
+
...adminIamEndpoints,
|
|
953
|
+
...Object.values(iamEndpoints) as EndpointDefinition[],
|
|
954
|
+
...(mountApiKeyRoutes ? adminApiKeyEndpoints : []),
|
|
955
|
+
].map(ep => [`${ep.method}_${ep.path}`, ep]),
|
|
956
|
+
),
|
|
957
|
+
|
|
958
|
+
methods: (ctx: PluginContext) => ({
|
|
959
|
+
async getResources(): Promise<ResourceFile> {
|
|
960
|
+
return pullResources(ctx.db);
|
|
961
|
+
},
|
|
962
|
+
|
|
963
|
+
async bootstrap(
|
|
964
|
+
body: { userId?: string; secret?: string },
|
|
965
|
+
routeCtx?: PluginRouteContext,
|
|
966
|
+
): Promise<{ ok: boolean; role: Role }> {
|
|
967
|
+
if (!mountBootstrap)
|
|
968
|
+
throw Errors.notFound('Admin bootstrap route is disabled');
|
|
969
|
+
if (!bootstrapSecret)
|
|
970
|
+
throw Errors.forbidden('Admin bootstrap secret is not configured');
|
|
971
|
+
if (!body.secret || !timingSafeEqual(body.secret, bootstrapSecret))
|
|
972
|
+
throw Errors.forbidden('Invalid admin bootstrap secret');
|
|
973
|
+
|
|
974
|
+
const callerId = routeCtx?.userId;
|
|
975
|
+
const userId = body.userId ?? callerId;
|
|
976
|
+
if (userId == null)
|
|
977
|
+
throw Errors.unauthorized('User not authenticated');
|
|
978
|
+
const db = ctx.db;
|
|
979
|
+
|
|
980
|
+
return db.transaction(async (tx) => {
|
|
981
|
+
// Serialize the one-time gate across processes. SQLite transaction
|
|
982
|
+
// adapters acquire a writer lock; PostgreSQL uses an advisory lock.
|
|
983
|
+
if (tx.dialect === 'pg' && tx.rawQuery)
|
|
984
|
+
await tx.rawQuery('SELECT pg_advisory_xact_lock(hashtext(?))', ['fortress-admin-bootstrap']);
|
|
985
|
+
|
|
986
|
+
// Verify user exists
|
|
987
|
+
const user = await tx.findOne<{ id: string }>({
|
|
988
|
+
model: 'user',
|
|
989
|
+
where: [{ field: 'id', operator: '=', value: userId }],
|
|
990
|
+
});
|
|
991
|
+
if (!user) {
|
|
992
|
+
throw Errors.notFound('User not found');
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// One-time bootstrap: any existing fortress-admin binding closes
|
|
996
|
+
// the bootstrap path permanently. A plain authenticated caller can
|
|
997
|
+
// never self-grant without the secret, and nobody can re-bootstrap.
|
|
998
|
+
const existingRole = await tx.findOne<Role>({
|
|
999
|
+
model: 'role',
|
|
1000
|
+
where: [{ field: 'name', operator: '=', value: 'fortress-admin' }],
|
|
1001
|
+
});
|
|
1002
|
+
if (existingRole) {
|
|
1003
|
+
const existingAdmins = await tx.count({
|
|
1004
|
+
model: 'role_binding',
|
|
1005
|
+
where: [{ field: 'roleId', operator: '=', value: existingRole.id }],
|
|
1006
|
+
});
|
|
1007
|
+
if (existingAdmins > 0)
|
|
1008
|
+
throw Errors.forbidden('Admin already bootstrapped');
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// Auto-discover all permissions from endpoint definitions
|
|
1012
|
+
const plugins = ctx.config.plugins ?? [];
|
|
1013
|
+
const pluginEndpoints: EndpointDefinition[] = [];
|
|
1014
|
+
for (const plugin of plugins) {
|
|
1015
|
+
if (plugin.routes)
|
|
1016
|
+
pluginEndpoints.push(...Object.values(plugin.routes) as EndpointDefinition[]);
|
|
1017
|
+
}
|
|
1018
|
+
const allEndpoints: EndpointDefinition[] = [
|
|
1019
|
+
...Object.values(authEndpoints) as EndpointDefinition[],
|
|
1020
|
+
...Object.values(iamEndpoints) as EndpointDefinition[],
|
|
1021
|
+
...pluginEndpoints,
|
|
1022
|
+
];
|
|
1023
|
+
const declaredPermissions = collectPermissions(allEndpoints);
|
|
1024
|
+
|
|
1025
|
+
// Ensure each resource exists
|
|
1026
|
+
const resources = new Set(declaredPermissions.map(p => p.resource));
|
|
1027
|
+
for (const resource of resources) {
|
|
1028
|
+
const existing = await tx.findOne<{ name: string }>({
|
|
1029
|
+
model: 'resource',
|
|
1030
|
+
where: [{ field: 'name', operator: '=', value: resource }],
|
|
1031
|
+
});
|
|
1032
|
+
if (!existing) {
|
|
1033
|
+
await tx.create({ model: 'resource', data: { name: resource, description: `Auto-registered by admin plugin` } });
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// Find or create each permission
|
|
1038
|
+
const permissionIds: string[] = [];
|
|
1039
|
+
for (const perm of declaredPermissions) {
|
|
1040
|
+
let existing = await tx.findOne<{ id: string }>({
|
|
1041
|
+
model: 'permission',
|
|
1042
|
+
where: [
|
|
1043
|
+
{ field: 'resource', operator: '=', value: perm.resource },
|
|
1044
|
+
{ field: 'action', operator: '=', value: perm.action },
|
|
1045
|
+
],
|
|
1046
|
+
});
|
|
1047
|
+
if (!existing) {
|
|
1048
|
+
existing = await tx.create<{ id: string }>({
|
|
1049
|
+
model: 'permission',
|
|
1050
|
+
data: {
|
|
1051
|
+
resource: perm.resource,
|
|
1052
|
+
action: perm.action,
|
|
1053
|
+
effect: 'ALLOW',
|
|
1054
|
+
description: `${perm.action} ${perm.resource}`,
|
|
1055
|
+
},
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
permissionIds.push(existing.id);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// Create or find the fortress-admin role
|
|
1062
|
+
let adminRole = existingRole;
|
|
1063
|
+
if (!adminRole) {
|
|
1064
|
+
adminRole = await tx.create<Role>({
|
|
1065
|
+
model: 'role',
|
|
1066
|
+
data: { name: 'fortress-admin', description: 'Full fortress administration', isSystem: true },
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// Link all permissions to the role
|
|
1071
|
+
for (const permId of permissionIds) {
|
|
1072
|
+
const existingLink = await tx.findOne<{ id: string }>({
|
|
1073
|
+
model: 'role_permission',
|
|
1074
|
+
where: [
|
|
1075
|
+
{ field: 'roleId', operator: '=', value: adminRole.id },
|
|
1076
|
+
{ field: 'permissionId', operator: '=', value: permId },
|
|
1077
|
+
],
|
|
1078
|
+
});
|
|
1079
|
+
if (!existingLink) {
|
|
1080
|
+
await tx.create({ model: 'role_permission', data: { roleId: adminRole.id, permissionId: permId } });
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// Bind the role to the user
|
|
1085
|
+
const existingBinding = await tx.findOne<{ id: string }>({
|
|
1086
|
+
model: 'role_binding',
|
|
1087
|
+
where: [
|
|
1088
|
+
{ field: 'roleId', operator: '=', value: adminRole.id },
|
|
1089
|
+
{ field: 'subjectType', operator: '=', value: 'USER' },
|
|
1090
|
+
{ field: 'subjectId', operator: '=', value: userId },
|
|
1091
|
+
],
|
|
1092
|
+
});
|
|
1093
|
+
if (!existingBinding) {
|
|
1094
|
+
await tx.create({
|
|
1095
|
+
model: 'role_binding',
|
|
1096
|
+
data: { roleId: adminRole.id, subjectType: 'USER', subjectId: userId, tenantId: null },
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
return { ok: true, role: adminRole };
|
|
1101
|
+
});
|
|
1102
|
+
},
|
|
1103
|
+
|
|
1104
|
+
// ── Auth admin — delegates to core auth service ──────────
|
|
1105
|
+
|
|
1106
|
+
async listUsers(body: Record<string, string>): Promise<{ users: FortressUser[]; total: number }> {
|
|
1107
|
+
if (!ctx.auth)
|
|
1108
|
+
throw Errors.database('Auth service not available');
|
|
1109
|
+
return ctx.auth.listUsers({
|
|
1110
|
+
limit: body.limit ? Number(body.limit) : undefined,
|
|
1111
|
+
offset: body.offset ? Number(body.offset) : undefined,
|
|
1112
|
+
search: body.search,
|
|
1113
|
+
sortBy: body.sortBy,
|
|
1114
|
+
sortDirection: body.sortDirection as 'asc' | 'desc' | undefined,
|
|
1115
|
+
});
|
|
1116
|
+
},
|
|
1117
|
+
|
|
1118
|
+
async getUserById(body: Record<string, string>): Promise<FortressUser> {
|
|
1119
|
+
if (!ctx.auth)
|
|
1120
|
+
throw Errors.database('Auth service not available');
|
|
1121
|
+
return ctx.auth.getUserById(requireId(body.id, 'id'));
|
|
1122
|
+
},
|
|
1123
|
+
|
|
1124
|
+
async createUser(body: Record<string, unknown>): Promise<FortressUser> {
|
|
1125
|
+
if (!ctx.auth)
|
|
1126
|
+
throw Errors.database('Auth service not available');
|
|
1127
|
+
return ctx.auth.createUser({
|
|
1128
|
+
email: body.email as string,
|
|
1129
|
+
name: body.name as string,
|
|
1130
|
+
password: body.password as string | undefined,
|
|
1131
|
+
isActive: body.isActive as boolean | undefined,
|
|
1132
|
+
});
|
|
1133
|
+
},
|
|
1134
|
+
|
|
1135
|
+
async updateUser(body: Record<string, unknown>): Promise<FortressUser> {
|
|
1136
|
+
if (!ctx.auth)
|
|
1137
|
+
throw Errors.database('Auth service not available');
|
|
1138
|
+
const { id, ...data } = body;
|
|
1139
|
+
return ctx.auth.updateUser(requireId(id, 'id'), data as { name?: string; email?: string; isActive?: boolean; password?: string });
|
|
1140
|
+
},
|
|
1141
|
+
|
|
1142
|
+
async deleteUser(body: Record<string, string>): Promise<{ ok: boolean }> {
|
|
1143
|
+
if (!ctx.auth)
|
|
1144
|
+
throw Errors.database('Auth service not available');
|
|
1145
|
+
await ctx.auth.deleteUser(requireId(body.id, 'id'));
|
|
1146
|
+
return { ok: true };
|
|
1147
|
+
},
|
|
1148
|
+
|
|
1149
|
+
// ── IAM admin — delegates to core IAM service ────────────
|
|
1150
|
+
|
|
1151
|
+
async getRole(body: Record<string, string>): Promise<Role & { permissions: Permission[] }> {
|
|
1152
|
+
if (!ctx.iam)
|
|
1153
|
+
throw Errors.database('IAM service not available');
|
|
1154
|
+
return ctx.iam.getRole(requireId(body.id, 'id'));
|
|
1155
|
+
},
|
|
1156
|
+
|
|
1157
|
+
async updateRole(body: Record<string, unknown>): Promise<Role> {
|
|
1158
|
+
if (!ctx.iam)
|
|
1159
|
+
throw Errors.database('IAM service not available');
|
|
1160
|
+
const { id, ...data } = body;
|
|
1161
|
+
return ctx.iam.updateRole(requireId(id, 'id'), data as { name?: string; description?: string });
|
|
1162
|
+
},
|
|
1163
|
+
|
|
1164
|
+
async listGroups(body: Record<string, string>): Promise<{ groups: Group[]; total: number }> {
|
|
1165
|
+
if (!ctx.iam)
|
|
1166
|
+
throw Errors.database('IAM service not available');
|
|
1167
|
+
return ctx.iam.listGroups({
|
|
1168
|
+
limit: body.limit ? Number(body.limit) : undefined,
|
|
1169
|
+
offset: body.offset ? Number(body.offset) : undefined,
|
|
1170
|
+
});
|
|
1171
|
+
},
|
|
1172
|
+
|
|
1173
|
+
async getGroup(body: Record<string, string>): Promise<Group & { users: FortressUser[] }> {
|
|
1174
|
+
if (!ctx.iam)
|
|
1175
|
+
throw Errors.database('IAM service not available');
|
|
1176
|
+
return ctx.iam.getGroup(requireId(body.id, 'id'));
|
|
1177
|
+
},
|
|
1178
|
+
|
|
1179
|
+
async updateGroup(body: Record<string, unknown>): Promise<Group> {
|
|
1180
|
+
if (!ctx.iam)
|
|
1181
|
+
throw Errors.database('IAM service not available');
|
|
1182
|
+
const { id, ...data } = body;
|
|
1183
|
+
return ctx.iam.updateGroup(requireId(id, 'id'), data as { name?: string; description?: string });
|
|
1184
|
+
},
|
|
1185
|
+
|
|
1186
|
+
async deleteGroup(body: Record<string, string>): Promise<{ ok: boolean }> {
|
|
1187
|
+
if (!ctx.iam)
|
|
1188
|
+
throw Errors.database('IAM service not available');
|
|
1189
|
+
await ctx.iam.deleteGroup(requireId(body.id, 'id'));
|
|
1190
|
+
return { ok: true };
|
|
1191
|
+
},
|
|
1192
|
+
|
|
1193
|
+
async getGroupUsers(body: Record<string, string>): Promise<FortressUser[]> {
|
|
1194
|
+
if (!ctx.iam)
|
|
1195
|
+
throw Errors.database('IAM service not available');
|
|
1196
|
+
return ctx.iam.getGroupUsers(requireId(body.id, 'id'));
|
|
1197
|
+
},
|
|
1198
|
+
|
|
1199
|
+
async listPermissions(body: Record<string, string>): Promise<Permission[]> {
|
|
1200
|
+
if (!ctx.iam)
|
|
1201
|
+
throw Errors.database('IAM service not available');
|
|
1202
|
+
return ctx.iam.listPermissions({
|
|
1203
|
+
resource: body.resource || undefined,
|
|
1204
|
+
});
|
|
1205
|
+
},
|
|
1206
|
+
|
|
1207
|
+
async createPermission(body: PermissionInput): Promise<Permission> {
|
|
1208
|
+
if (!ctx.iam)
|
|
1209
|
+
throw Errors.database('IAM service not available');
|
|
1210
|
+
return ctx.iam.createPermission(body);
|
|
1211
|
+
},
|
|
1212
|
+
|
|
1213
|
+
async deletePermission(body: Record<string, string>): Promise<{ ok: boolean }> {
|
|
1214
|
+
if (!ctx.iam)
|
|
1215
|
+
throw Errors.database('IAM service not available');
|
|
1216
|
+
await ctx.iam.deletePermission(requireId(body.id, 'id'));
|
|
1217
|
+
return { ok: true };
|
|
1218
|
+
},
|
|
1219
|
+
|
|
1220
|
+
async addPermissionToRole(body: Record<string, unknown>): Promise<{ ok: boolean }> {
|
|
1221
|
+
if (!ctx.iam)
|
|
1222
|
+
throw Errors.database('IAM service not available');
|
|
1223
|
+
const { id, ...permission } = body;
|
|
1224
|
+
await ctx.iam.addPermissionToRole(requireId(id, 'id'), permission as unknown as PermissionInput);
|
|
1225
|
+
return { ok: true };
|
|
1226
|
+
},
|
|
1227
|
+
|
|
1228
|
+
// ── Core IAM operations — previously spec-only ─────────────
|
|
1229
|
+
|
|
1230
|
+
async getRoles(): Promise<Role[]> {
|
|
1231
|
+
if (!ctx.iam)
|
|
1232
|
+
throw Errors.database('IAM service not available');
|
|
1233
|
+
return ctx.iam.getRoles();
|
|
1234
|
+
},
|
|
1235
|
+
|
|
1236
|
+
async createRole(body: Record<string, unknown>): Promise<Role> {
|
|
1237
|
+
if (!ctx.iam)
|
|
1238
|
+
throw Errors.database('IAM service not available');
|
|
1239
|
+
return ctx.iam.createRole(
|
|
1240
|
+
body.name as string,
|
|
1241
|
+
(body.permissions ?? []) as PermissionInput[],
|
|
1242
|
+
body.description as string | undefined,
|
|
1243
|
+
);
|
|
1244
|
+
},
|
|
1245
|
+
|
|
1246
|
+
async deleteRole(body: Record<string, unknown>): Promise<{ ok: boolean }> {
|
|
1247
|
+
if (!ctx.iam)
|
|
1248
|
+
throw Errors.database('IAM service not available');
|
|
1249
|
+
await ctx.iam.deleteRole(requireId(body.id, 'id'));
|
|
1250
|
+
return { ok: true };
|
|
1251
|
+
},
|
|
1252
|
+
|
|
1253
|
+
async bindRoleToUser(body: Record<string, unknown>): Promise<{ ok: boolean }> {
|
|
1254
|
+
if (!ctx.iam)
|
|
1255
|
+
throw Errors.database('IAM service not available');
|
|
1256
|
+
await ctx.iam.bindRoleToUser(
|
|
1257
|
+
requireId(body.userId, 'userId'),
|
|
1258
|
+
requireId(body.id, 'id'),
|
|
1259
|
+
body.tenantId as string | undefined,
|
|
1260
|
+
);
|
|
1261
|
+
return { ok: true };
|
|
1262
|
+
},
|
|
1263
|
+
|
|
1264
|
+
async bindRoleToGroup(body: Record<string, unknown>): Promise<{ ok: boolean }> {
|
|
1265
|
+
if (!ctx.iam)
|
|
1266
|
+
throw Errors.database('IAM service not available');
|
|
1267
|
+
await ctx.iam.bindRoleToGroup(
|
|
1268
|
+
requireId(body.groupId, 'groupId'),
|
|
1269
|
+
requireId(body.id, 'id'),
|
|
1270
|
+
body.tenantId as string | undefined,
|
|
1271
|
+
);
|
|
1272
|
+
return { ok: true };
|
|
1273
|
+
},
|
|
1274
|
+
|
|
1275
|
+
async unbindRole(body: Record<string, unknown>): Promise<{ ok: boolean }> {
|
|
1276
|
+
if (!ctx.iam)
|
|
1277
|
+
throw Errors.database('IAM service not available');
|
|
1278
|
+
await ctx.iam.unbindRole(
|
|
1279
|
+
body.subjectType as SubjectType,
|
|
1280
|
+
requireId(body.subjectId, 'subjectId'),
|
|
1281
|
+
requireId(body.id, 'id'),
|
|
1282
|
+
body.tenantId as string | undefined,
|
|
1283
|
+
);
|
|
1284
|
+
return { ok: true };
|
|
1285
|
+
},
|
|
1286
|
+
|
|
1287
|
+
async createGroup(body: Record<string, unknown>): Promise<Group> {
|
|
1288
|
+
if (!ctx.iam)
|
|
1289
|
+
throw Errors.database('IAM service not available');
|
|
1290
|
+
return ctx.iam.createGroup(
|
|
1291
|
+
body.name as string,
|
|
1292
|
+
body.description as string | undefined,
|
|
1293
|
+
);
|
|
1294
|
+
},
|
|
1295
|
+
|
|
1296
|
+
async addUserToGroup(body: Record<string, unknown>): Promise<{ ok: boolean }> {
|
|
1297
|
+
if (!ctx.iam)
|
|
1298
|
+
throw Errors.database('IAM service not available');
|
|
1299
|
+
await ctx.iam.addUserToGroup(
|
|
1300
|
+
requireId(body.id, 'id'),
|
|
1301
|
+
requireId(body.userId, 'userId'),
|
|
1302
|
+
);
|
|
1303
|
+
return { ok: true };
|
|
1304
|
+
},
|
|
1305
|
+
|
|
1306
|
+
async removeUserFromGroup(body: Record<string, unknown>): Promise<{ ok: boolean }> {
|
|
1307
|
+
if (!ctx.iam)
|
|
1308
|
+
throw Errors.database('IAM service not available');
|
|
1309
|
+
await ctx.iam.removeUserFromGroup(
|
|
1310
|
+
requireId(body.id, 'id'),
|
|
1311
|
+
requireId(body.userId, 'userId'),
|
|
1312
|
+
);
|
|
1313
|
+
return { ok: true };
|
|
1314
|
+
},
|
|
1315
|
+
|
|
1316
|
+
async getUserPermissions(body: Record<string, unknown>): Promise<Permission[]> {
|
|
1317
|
+
if (!ctx.iam)
|
|
1318
|
+
throw Errors.database('IAM service not available');
|
|
1319
|
+
return ctx.iam.getPermissionsForSubject(
|
|
1320
|
+
{ type: 'USER', id: requireId(body.id, 'id') },
|
|
1321
|
+
body.tenantId as string | undefined,
|
|
1322
|
+
);
|
|
1323
|
+
},
|
|
1324
|
+
|
|
1325
|
+
async checkPermission(body: Record<string, unknown>): Promise<{ allowed: boolean }> {
|
|
1326
|
+
if (!ctx.iam)
|
|
1327
|
+
throw Errors.database('IAM service not available');
|
|
1328
|
+
const allowed = await ctx.iam.checkPermission(
|
|
1329
|
+
{ type: 'USER', id: requireId(body.userId, 'userId') },
|
|
1330
|
+
body.resource as string,
|
|
1331
|
+
body.action as string,
|
|
1332
|
+
body.context as Record<string, unknown> | undefined,
|
|
1333
|
+
);
|
|
1334
|
+
return { allowed };
|
|
1335
|
+
},
|
|
1336
|
+
|
|
1337
|
+
async bindPermissionToUser(body: Record<string, unknown>): Promise<{ ok: boolean }> {
|
|
1338
|
+
if (!ctx.iam)
|
|
1339
|
+
throw Errors.database('IAM service not available');
|
|
1340
|
+
await ctx.iam.bindPermissionToUser(
|
|
1341
|
+
requireId(body.userId, 'userId'),
|
|
1342
|
+
body.permission as PermissionInput,
|
|
1343
|
+
body.tenantId as string | undefined,
|
|
1344
|
+
);
|
|
1345
|
+
return { ok: true };
|
|
1346
|
+
},
|
|
1347
|
+
|
|
1348
|
+
async bindPermissionToGroup(body: Record<string, unknown>): Promise<{ ok: boolean }> {
|
|
1349
|
+
if (!ctx.iam)
|
|
1350
|
+
throw Errors.database('IAM service not available');
|
|
1351
|
+
await ctx.iam.bindPermissionToGroup(
|
|
1352
|
+
requireId(body.groupId, 'groupId'),
|
|
1353
|
+
body.permission as PermissionInput,
|
|
1354
|
+
body.tenantId as string | undefined,
|
|
1355
|
+
);
|
|
1356
|
+
return { ok: true };
|
|
1357
|
+
},
|
|
1358
|
+
|
|
1359
|
+
async unbindPermissionFromUser(body: Record<string, unknown>): Promise<{ ok: boolean }> {
|
|
1360
|
+
if (!ctx.iam)
|
|
1361
|
+
throw Errors.database('IAM service not available');
|
|
1362
|
+
await ctx.iam.unbindPermissionFromUser(
|
|
1363
|
+
requireId(body.userId, 'userId'),
|
|
1364
|
+
requireId(body.permissionId, 'permissionId'),
|
|
1365
|
+
body.tenantId as string | undefined,
|
|
1366
|
+
);
|
|
1367
|
+
return { ok: true };
|
|
1368
|
+
},
|
|
1369
|
+
|
|
1370
|
+
async unbindPermissionFromGroup(body: Record<string, unknown>): Promise<{ ok: boolean }> {
|
|
1371
|
+
if (!ctx.iam)
|
|
1372
|
+
throw Errors.database('IAM service not available');
|
|
1373
|
+
await ctx.iam.unbindPermissionFromGroup(
|
|
1374
|
+
requireId(body.groupId, 'groupId'),
|
|
1375
|
+
requireId(body.permissionId, 'permissionId'),
|
|
1376
|
+
body.tenantId as string | undefined,
|
|
1377
|
+
);
|
|
1378
|
+
return { ok: true };
|
|
1379
|
+
},
|
|
1380
|
+
|
|
1381
|
+
// ── Service-account IAM proxies ────────────────────────────
|
|
1382
|
+
//
|
|
1383
|
+
// The admin plugin re-exports `iamEndpoints` on its own `routes` so
|
|
1384
|
+
// that IAM routes get dispatched through `dispatchPlugin`. That
|
|
1385
|
+
// means every IAM handler referenced by those endpoint definitions
|
|
1386
|
+
// must exist on this plugin's methods object — thin proxies that
|
|
1387
|
+
// delegate to `ctx.iam`. Without these, hitting any
|
|
1388
|
+
// `/iam/service-accounts/*` route through an instance that has the
|
|
1389
|
+
// admin plugin registered returns a 404 from the dispatcher.
|
|
1390
|
+
|
|
1391
|
+
async createServiceAccount(body: Record<string, unknown>): Promise<unknown> {
|
|
1392
|
+
if (!ctx.iam)
|
|
1393
|
+
throw Errors.database('IAM service not available');
|
|
1394
|
+
return ctx.iam.createServiceAccount({
|
|
1395
|
+
name: String(body.name ?? ''),
|
|
1396
|
+
displayName: body.displayName as string | undefined,
|
|
1397
|
+
description: body.description as string | undefined,
|
|
1398
|
+
});
|
|
1399
|
+
},
|
|
1400
|
+
|
|
1401
|
+
async listServiceAccounts(body: Record<string, unknown>): Promise<unknown> {
|
|
1402
|
+
if (!ctx.iam)
|
|
1403
|
+
throw Errors.database('IAM service not available');
|
|
1404
|
+
return ctx.iam.listServiceAccounts({
|
|
1405
|
+
limit: body.limit != null ? Number(body.limit) : undefined,
|
|
1406
|
+
offset: body.offset != null ? Number(body.offset) : undefined,
|
|
1407
|
+
});
|
|
1408
|
+
},
|
|
1409
|
+
|
|
1410
|
+
async getServiceAccount(body: Record<string, unknown>): Promise<unknown> {
|
|
1411
|
+
if (!ctx.iam)
|
|
1412
|
+
throw Errors.database('IAM service not available');
|
|
1413
|
+
return ctx.iam.getServiceAccount(requireId(body.id, 'id'));
|
|
1414
|
+
},
|
|
1415
|
+
|
|
1416
|
+
async updateServiceAccount(body: Record<string, unknown>): Promise<unknown> {
|
|
1417
|
+
if (!ctx.iam)
|
|
1418
|
+
throw Errors.database('IAM service not available');
|
|
1419
|
+
return ctx.iam.updateServiceAccount(requireId(body.id, 'id'), {
|
|
1420
|
+
displayName: body.displayName as string | null | undefined,
|
|
1421
|
+
description: body.description as string | null | undefined,
|
|
1422
|
+
isActive: body.isActive as boolean | undefined,
|
|
1423
|
+
});
|
|
1424
|
+
},
|
|
1425
|
+
|
|
1426
|
+
async deleteServiceAccount(body: Record<string, unknown>): Promise<{ ok: boolean }> {
|
|
1427
|
+
if (!ctx.iam)
|
|
1428
|
+
throw Errors.database('IAM service not available');
|
|
1429
|
+
await ctx.iam.deleteServiceAccount(requireId(body.id, 'id'));
|
|
1430
|
+
return { ok: true };
|
|
1431
|
+
},
|
|
1432
|
+
|
|
1433
|
+
async getServiceAccountPermissions(body: Record<string, unknown>): Promise<Permission[]> {
|
|
1434
|
+
if (!ctx.iam)
|
|
1435
|
+
throw Errors.database('IAM service not available');
|
|
1436
|
+
return ctx.iam.getPermissionsForSubject(
|
|
1437
|
+
{ type: 'SERVICE_ACCOUNT', id: requireId(body.id, 'id') },
|
|
1438
|
+
body.tenantId as string | undefined,
|
|
1439
|
+
);
|
|
1440
|
+
},
|
|
1441
|
+
|
|
1442
|
+
async bindRoleToServiceAccount(body: Record<string, unknown>): Promise<{ ok: boolean }> {
|
|
1443
|
+
if (!ctx.iam)
|
|
1444
|
+
throw Errors.database('IAM service not available');
|
|
1445
|
+
await ctx.iam.bindRoleToServiceAccount(
|
|
1446
|
+
requireId(body.serviceAccountId, 'serviceAccountId'),
|
|
1447
|
+
requireId(body.id, 'id'),
|
|
1448
|
+
body.tenantId as string | undefined,
|
|
1449
|
+
);
|
|
1450
|
+
return { ok: true };
|
|
1451
|
+
},
|
|
1452
|
+
|
|
1453
|
+
async unbindRoleFromServiceAccount(body: Record<string, unknown>): Promise<{ ok: boolean }> {
|
|
1454
|
+
if (!ctx.iam)
|
|
1455
|
+
throw Errors.database('IAM service not available');
|
|
1456
|
+
await ctx.iam.unbindRoleFromServiceAccount(
|
|
1457
|
+
requireId(body.serviceAccountId, 'serviceAccountId'),
|
|
1458
|
+
requireId(body.id, 'id'),
|
|
1459
|
+
body.tenantId as string | undefined,
|
|
1460
|
+
);
|
|
1461
|
+
return { ok: true };
|
|
1462
|
+
},
|
|
1463
|
+
|
|
1464
|
+
async bindPermissionToServiceAccount(body: Record<string, unknown>): Promise<{ ok: boolean }> {
|
|
1465
|
+
if (!ctx.iam)
|
|
1466
|
+
throw Errors.database('IAM service not available');
|
|
1467
|
+
await ctx.iam.bindPermissionToServiceAccount(
|
|
1468
|
+
requireId(body.serviceAccountId, 'serviceAccountId'),
|
|
1469
|
+
body.permission as PermissionInput,
|
|
1470
|
+
body.tenantId as string | undefined,
|
|
1471
|
+
);
|
|
1472
|
+
return { ok: true };
|
|
1473
|
+
},
|
|
1474
|
+
|
|
1475
|
+
async unbindPermissionFromServiceAccount(body: Record<string, unknown>): Promise<{ ok: boolean }> {
|
|
1476
|
+
if (!ctx.iam)
|
|
1477
|
+
throw Errors.database('IAM service not available');
|
|
1478
|
+
await ctx.iam.unbindPermissionFromServiceAccount(
|
|
1479
|
+
requireId(body.serviceAccountId, 'serviceAccountId'),
|
|
1480
|
+
requireId(body.permissionId, 'permissionId'),
|
|
1481
|
+
body.tenantId as string | undefined,
|
|
1482
|
+
);
|
|
1483
|
+
return { ok: true };
|
|
1484
|
+
},
|
|
1485
|
+
|
|
1486
|
+
// ── Admin api-key management ───────────────────────────────
|
|
1487
|
+
//
|
|
1488
|
+
// Read/delete delegate to the stateless helpers in `api-key/core.ts`.
|
|
1489
|
+
// Create re-enters the api-key plugin's `methods` factory so the
|
|
1490
|
+
// configured knobs (prefix, maxKeysPerSubject, defaultExpirySeconds)
|
|
1491
|
+
// apply to admin-minted keys exactly as they do to self-service
|
|
1492
|
+
// keys — no duplicate config. All of these are available as
|
|
1493
|
+
// programmatic methods regardless of the `apiKeyRoutes` flag — only
|
|
1494
|
+
// the HTTP mounting is gated. Requires the `api-key` plugin to be
|
|
1495
|
+
// registered (for the `api_key` model).
|
|
1496
|
+
|
|
1497
|
+
async adminCreateUserApiKey(
|
|
1498
|
+
body: Record<string, unknown>,
|
|
1499
|
+
): Promise<{ key: string; id: string }> {
|
|
1500
|
+
const userId = requireId(body.userId, 'userId');
|
|
1501
|
+
// Verify the user exists so we never mint an orphan key.
|
|
1502
|
+
const user = await ctx.db.findOne<{ id: string }>({
|
|
1503
|
+
model: 'user',
|
|
1504
|
+
where: [{ field: 'id', operator: '=', value: userId }],
|
|
1505
|
+
});
|
|
1506
|
+
if (!user)
|
|
1507
|
+
throw Errors.notFound('User not found');
|
|
1508
|
+
const apiKeyMethods = getApiKeyMethods(ctx);
|
|
1509
|
+
// Intentionally no routeCtx — we want `input.subject` to target
|
|
1510
|
+
// the named user, not the admin who's calling us.
|
|
1511
|
+
return apiKeyMethods.createKey({
|
|
1512
|
+
subject: { type: 'USER', id: userId },
|
|
1513
|
+
name: String(body.name ?? ''),
|
|
1514
|
+
scopes: Array.isArray(body.scopes) ? (body.scopes as string[]).map(String) : undefined,
|
|
1515
|
+
expiresAt: body.expiresAt as string | Date | undefined,
|
|
1516
|
+
});
|
|
1517
|
+
},
|
|
1518
|
+
|
|
1519
|
+
async adminListUserApiKeys(
|
|
1520
|
+
body: Record<string, unknown>,
|
|
1521
|
+
): Promise<{ keys: ApiKeyInfo[] }> {
|
|
1522
|
+
const keys = await listKeysForSubject(ctx.db, {
|
|
1523
|
+
type: 'USER',
|
|
1524
|
+
id: requireId(body.userId, 'userId'),
|
|
1525
|
+
});
|
|
1526
|
+
return { keys };
|
|
1527
|
+
},
|
|
1528
|
+
|
|
1529
|
+
async adminRevokeUserApiKey(
|
|
1530
|
+
body: Record<string, unknown>,
|
|
1531
|
+
): Promise<{ ok: boolean }> {
|
|
1532
|
+
await revokeKeyAsAdmin(ctx.db, requireId(body.id, 'id'));
|
|
1533
|
+
return { ok: true };
|
|
1534
|
+
},
|
|
1535
|
+
|
|
1536
|
+
// ── Service-account-scoped admin api-key handlers ─────────
|
|
1537
|
+
|
|
1538
|
+
async adminCreateServiceAccountApiKey(
|
|
1539
|
+
body: Record<string, unknown>,
|
|
1540
|
+
): Promise<{ key: string; id: string }> {
|
|
1541
|
+
const serviceAccountId = requireId(body.id, 'id');
|
|
1542
|
+
// Verify the service account exists so we never mint an orphan
|
|
1543
|
+
// key. Inactive service accounts are allowed here — the key will
|
|
1544
|
+
// just fail to authenticate until the account is reactivated,
|
|
1545
|
+
// which is occasionally useful for pre-provisioning.
|
|
1546
|
+
const sa = await ctx.db.findOne<{ id: string }>({
|
|
1547
|
+
model: 'service_account',
|
|
1548
|
+
where: [{ field: 'id', operator: '=', value: serviceAccountId }],
|
|
1549
|
+
});
|
|
1550
|
+
if (!sa)
|
|
1551
|
+
throw Errors.notFound('Service account not found');
|
|
1552
|
+
const apiKeyMethods = getApiKeyMethods(ctx);
|
|
1553
|
+
return apiKeyMethods.createKey({
|
|
1554
|
+
subject: { type: 'SERVICE_ACCOUNT', id: serviceAccountId },
|
|
1555
|
+
name: String(body.name ?? ''),
|
|
1556
|
+
scopes: Array.isArray(body.scopes) ? (body.scopes as string[]).map(String) : undefined,
|
|
1557
|
+
expiresAt: body.expiresAt as string | Date | undefined,
|
|
1558
|
+
});
|
|
1559
|
+
},
|
|
1560
|
+
|
|
1561
|
+
async adminListServiceAccountApiKeys(
|
|
1562
|
+
body: Record<string, unknown>,
|
|
1563
|
+
): Promise<{ keys: ApiKeyInfo[] }> {
|
|
1564
|
+
const keys = await listKeysForSubject(ctx.db, {
|
|
1565
|
+
type: 'SERVICE_ACCOUNT',
|
|
1566
|
+
id: requireId(body.id, 'id'),
|
|
1567
|
+
});
|
|
1568
|
+
return { keys };
|
|
1569
|
+
},
|
|
1570
|
+
|
|
1571
|
+
async adminRevokeServiceAccountApiKey(
|
|
1572
|
+
body: Record<string, unknown>,
|
|
1573
|
+
): Promise<{ ok: boolean }> {
|
|
1574
|
+
await revokeKeyAsAdmin(ctx.db, requireId(body.keyId, 'keyId'));
|
|
1575
|
+
return { ok: true };
|
|
1576
|
+
},
|
|
1577
|
+
|
|
1578
|
+
// ── Resource sync ──────────────────────────────────────────
|
|
1579
|
+
|
|
1580
|
+
async syncResources(body: Record<string, unknown>): Promise<{ ok: boolean }> {
|
|
1581
|
+
if (!ctx.iam)
|
|
1582
|
+
throw Errors.database('IAM service not available');
|
|
1583
|
+
await ctx.iam.syncResources(
|
|
1584
|
+
body.direction as 'push' | 'pull',
|
|
1585
|
+
body.filePath as string | undefined,
|
|
1586
|
+
);
|
|
1587
|
+
return { ok: true };
|
|
1588
|
+
},
|
|
1589
|
+
}),
|
|
1590
|
+
};
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
// --- Helpers ---
|
|
1594
|
+
|
|
1595
|
+
/**
|
|
1596
|
+
* Look up the api-key plugin on the fortress config and return its
|
|
1597
|
+
* methods bound to the current context. Used by the admin plugin's
|
|
1598
|
+
* create-key handlers so admin-minted keys go through the same
|
|
1599
|
+
* configured knobs (prefix, limits, default expiry) as self-service
|
|
1600
|
+
* keys — no duplicate config.
|
|
1601
|
+
*
|
|
1602
|
+
* Rerunning the api-key plugin's `methods` factory is cheap and safe:
|
|
1603
|
+
* it just returns a fresh object literal, and the plugin's internal
|
|
1604
|
+
* `observerRegistered` flag prevents the IAM cascade observer from
|
|
1605
|
+
* being attached twice.
|
|
1606
|
+
*/
|
|
1607
|
+
function getApiKeyMethods(ctx: PluginContext): ApiKeyMethods {
|
|
1608
|
+
const apiKeyPlugin = (ctx.config.plugins ?? []).find(p => p.name === 'api-key');
|
|
1609
|
+
if (!apiKeyPlugin?.methods)
|
|
1610
|
+
throw Errors.database('api-key plugin is not registered');
|
|
1611
|
+
return apiKeyPlugin.methods(ctx) as unknown as ApiKeyMethods;
|
|
1612
|
+
}
|