@ddd-ts-kit/auth 0.0.0-stage → 0.1.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/README.md +834 -2
- package/dist/abac/attribute-policy.d.ts +13 -0
- package/dist/abac/attribute-policy.d.ts.map +1 -0
- package/dist/abac/attribute-policy.js +10 -0
- package/dist/abac/attribute-policy.js.map +1 -0
- package/dist/abac/condition.d.ts +6 -0
- package/dist/abac/condition.d.ts.map +1 -0
- package/dist/abac/condition.js +10 -0
- package/dist/abac/condition.js.map +1 -0
- package/dist/abac/policy-based-authorizer.d.ts +23 -0
- package/dist/abac/policy-based-authorizer.d.ts.map +1 -0
- package/dist/abac/policy-based-authorizer.js +39 -0
- package/dist/abac/policy-based-authorizer.js.map +1 -0
- package/dist/abac/policy-input.d.ts +17 -0
- package/dist/abac/policy-input.d.ts.map +1 -0
- package/dist/abac/policy-input.js +2 -0
- package/dist/abac/policy-input.js.map +1 -0
- package/dist/abac/resource-attribute-values.d.ts +3 -0
- package/dist/abac/resource-attribute-values.d.ts.map +1 -0
- package/dist/abac/resource-attribute-values.js +2 -0
- package/dist/abac/resource-attribute-values.js.map +1 -0
- package/dist/abac/resource-attributes.d.ts +8 -0
- package/dist/abac/resource-attributes.d.ts.map +1 -0
- package/dist/abac/resource-attributes.js +5 -0
- package/dist/abac/resource-attributes.js.map +1 -0
- package/dist/authentication/actor-of.d.ts +7 -0
- package/dist/authentication/actor-of.d.ts.map +1 -0
- package/dist/authentication/actor-of.js +9 -0
- package/dist/authentication/actor-of.js.map +1 -0
- package/dist/authentication/authenticator.d.ts +11 -0
- package/dist/authentication/authenticator.d.ts.map +1 -0
- package/dist/authentication/authenticator.js +9 -0
- package/dist/authentication/authenticator.js.map +1 -0
- package/dist/authentication/bearer-credential.d.ts +7 -0
- package/dist/authentication/bearer-credential.d.ts.map +1 -0
- package/dist/authentication/bearer-credential.js +19 -0
- package/dist/authentication/bearer-credential.js.map +1 -0
- package/dist/authentication/header-values.d.ts +2 -0
- package/dist/authentication/header-values.d.ts.map +1 -0
- package/dist/authentication/header-values.js +2 -0
- package/dist/authentication/header-values.js.map +1 -0
- package/dist/authentication/in-actor-context.d.ts +15 -0
- package/dist/authentication/in-actor-context.d.ts.map +1 -0
- package/dist/authentication/in-actor-context.js +11 -0
- package/dist/authentication/in-actor-context.js.map +1 -0
- package/dist/authentication/principal-resolver.d.ts +10 -0
- package/dist/authentication/principal-resolver.d.ts.map +1 -0
- package/dist/authentication/principal-resolver.js +25 -0
- package/dist/authentication/principal-resolver.js.map +1 -0
- package/dist/authentication/resolve-session.d.ts +9 -0
- package/dist/authentication/resolve-session.d.ts.map +1 -0
- package/dist/authentication/resolve-session.js +12 -0
- package/dist/authentication/resolve-session.js.map +1 -0
- package/dist/authentication/session-authenticator.d.ts +13 -0
- package/dist/authentication/session-authenticator.d.ts.map +1 -0
- package/dist/authentication/session-authenticator.js +11 -0
- package/dist/authentication/session-authenticator.js.map +1 -0
- package/dist/authentication/session-of.d.ts +9 -0
- package/dist/authentication/session-of.d.ts.map +1 -0
- package/dist/authentication/session-of.js +27 -0
- package/dist/authentication/session-of.js.map +1 -0
- package/dist/authorization/access-scope.d.ts +16 -0
- package/dist/authorization/access-scope.d.ts.map +1 -0
- package/dist/authorization/access-scope.js +2 -0
- package/dist/authorization/access-scope.js.map +1 -0
- package/dist/authorization/access.d.ts +17 -0
- package/dist/authorization/access.d.ts.map +1 -0
- package/dist/authorization/access.js +8 -0
- package/dist/authorization/access.js.map +1 -0
- package/dist/authorization/authorization-context.d.ts +3 -0
- package/dist/authorization/authorization-context.d.ts.map +1 -0
- package/dist/authorization/authorization-context.js +2 -0
- package/dist/authorization/authorization-context.js.map +1 -0
- package/dist/authorization/authorizer.d.ts +15 -0
- package/dist/authorization/authorizer.d.ts.map +1 -0
- package/dist/authorization/authorizer.js +8 -0
- package/dist/authorization/authorizer.js.map +1 -0
- package/dist/authorization/check-request.d.ts +11 -0
- package/dist/authorization/check-request.d.ts.map +1 -0
- package/dist/authorization/check-request.js +2 -0
- package/dist/authorization/check-request.js.map +1 -0
- package/dist/authorization/decision-record-of.d.ts +7 -0
- package/dist/authorization/decision-record-of.d.ts.map +1 -0
- package/dist/authorization/decision-record-of.js +11 -0
- package/dist/authorization/decision-record-of.js.map +1 -0
- package/dist/authorization/decision-record.d.ts +15 -0
- package/dist/authorization/decision-record.d.ts.map +1 -0
- package/dist/authorization/decision-record.js +2 -0
- package/dist/authorization/decision-record.js.map +1 -0
- package/dist/authorization/decision.d.ts +10 -0
- package/dist/authorization/decision.d.ts.map +1 -0
- package/dist/authorization/decision.js +5 -0
- package/dist/authorization/decision.js.map +1 -0
- package/dist/authorization/denial-reason.d.ts +14 -0
- package/dist/authorization/denial-reason.d.ts.map +1 -0
- package/dist/authorization/denial-reason.js +14 -0
- package/dist/authorization/denial-reason.js.map +1 -0
- package/dist/authorization/is-resource-ref.d.ts +4 -0
- package/dist/authorization/is-resource-ref.d.ts.map +1 -0
- package/dist/authorization/is-resource-ref.js +11 -0
- package/dist/authorization/is-resource-ref.js.map +1 -0
- package/dist/authorization/list-request.d.ts +8 -0
- package/dist/authorization/list-request.d.ts.map +1 -0
- package/dist/authorization/list-request.js +2 -0
- package/dist/authorization/list-request.js.map +1 -0
- package/dist/authorization/on-decision.d.ts +3 -0
- package/dist/authorization/on-decision.d.ts.map +1 -0
- package/dist/authorization/on-decision.js +2 -0
- package/dist/authorization/on-decision.js.map +1 -0
- package/dist/authorization/resource-ref.d.ts +6 -0
- package/dist/authorization/resource-ref.d.ts.map +1 -0
- package/dist/authorization/resource-ref.js +2 -0
- package/dist/authorization/resource-ref.js.map +1 -0
- package/dist/di/authorization-map-of.d.ts +5 -0
- package/dist/di/authorization-map-of.d.ts.map +1 -0
- package/dist/di/authorization-map-of.js +30 -0
- package/dist/di/authorization-map-of.js.map +1 -0
- package/dist/di/authorization-module.d.ts +24 -0
- package/dist/di/authorization-module.d.ts.map +1 -0
- package/dist/di/authorization-module.js +75 -0
- package/dist/di/authorization-module.js.map +1 -0
- package/dist/di.d.ts +7 -0
- package/dist/di.d.ts.map +1 -0
- package/dist/di.js +7 -0
- package/dist/di.js.map +1 -0
- package/dist/errors/authorization-setup-error.d.ts +5 -0
- package/dist/errors/authorization-setup-error.d.ts.map +1 -0
- package/dist/errors/authorization-setup-error.js +8 -0
- package/dist/errors/authorization-setup-error.js.map +1 -0
- package/dist/errors/forbidden-error.d.ts +14 -0
- package/dist/errors/forbidden-error.d.ts.map +1 -0
- package/dist/errors/forbidden-error.js +18 -0
- package/dist/errors/forbidden-error.js.map +1 -0
- package/dist/errors/invalid-credential-error.d.ts +8 -0
- package/dist/errors/invalid-credential-error.d.ts.map +1 -0
- package/dist/errors/invalid-credential-error.js +12 -0
- package/dist/errors/invalid-credential-error.js.map +1 -0
- package/dist/errors/listing-not-supported-error.d.ts +5 -0
- package/dist/errors/listing-not-supported-error.d.ts.map +1 -0
- package/dist/errors/listing-not-supported-error.js +8 -0
- package/dist/errors/listing-not-supported-error.js.map +1 -0
- package/dist/errors/missing-actor-error.d.ts +6 -0
- package/dist/errors/missing-actor-error.d.ts.map +1 -0
- package/dist/errors/missing-actor-error.js +8 -0
- package/dist/errors/missing-actor-error.js.map +1 -0
- package/dist/errors/unauthenticated-error.d.ts +12 -0
- package/dist/errors/unauthenticated-error.d.ts.map +1 -0
- package/dist/errors/unauthenticated-error.js +14 -0
- package/dist/errors/unauthenticated-error.js.map +1 -0
- package/dist/errors/undeclared-use-case-error.d.ts +9 -0
- package/dist/errors/undeclared-use-case-error.d.ts.map +1 -0
- package/dist/errors/undeclared-use-case-error.js +11 -0
- package/dist/errors/undeclared-use-case-error.js.map +1 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/jwt/claims.d.ts +3 -0
- package/dist/jwt/claims.d.ts.map +1 -0
- package/dist/jwt/claims.js +2 -0
- package/dist/jwt/claims.js.map +1 -0
- package/dist/jwt/is-service-account.d.ts +3 -0
- package/dist/jwt/is-service-account.d.ts.map +1 -0
- package/dist/jwt/is-service-account.js +7 -0
- package/dist/jwt/is-service-account.js.map +1 -0
- package/dist/jwt/jwt-authenticator.d.ts +45 -0
- package/dist/jwt/jwt-authenticator.d.ts.map +1 -0
- package/dist/jwt/jwt-authenticator.js +109 -0
- package/dist/jwt/jwt-authenticator.js.map +1 -0
- package/dist/jwt/keycloak-actor-of.d.ts +6 -0
- package/dist/jwt/keycloak-actor-of.d.ts.map +1 -0
- package/dist/jwt/keycloak-actor-of.js +56 -0
- package/dist/jwt/keycloak-actor-of.js.map +1 -0
- package/dist/jwt/keycloak-authentication-method.d.ts +5 -0
- package/dist/jwt/keycloak-authentication-method.d.ts.map +1 -0
- package/dist/jwt/keycloak-authentication-method.js +6 -0
- package/dist/jwt/keycloak-authentication-method.js.map +1 -0
- package/dist/jwt/keycloak-authenticator.d.ts +23 -0
- package/dist/jwt/keycloak-authenticator.d.ts.map +1 -0
- package/dist/jwt/keycloak-authenticator.js +25 -0
- package/dist/jwt/keycloak-authenticator.js.map +1 -0
- package/dist/jwt/keycloak-claim-options.d.ts +9 -0
- package/dist/jwt/keycloak-claim-options.d.ts.map +1 -0
- package/dist/jwt/keycloak-claim-options.js +2 -0
- package/dist/jwt/keycloak-claim-options.js.map +1 -0
- package/dist/jwt/session-of-claims.d.ts +13 -0
- package/dist/jwt/session-of-claims.d.ts.map +1 -0
- package/dist/jwt/session-of-claims.js +71 -0
- package/dist/jwt/session-of-claims.js.map +1 -0
- package/dist/jwt/without-trailing-slashes.d.ts +3 -0
- package/dist/jwt/without-trailing-slashes.d.ts.map +1 -0
- package/dist/jwt/without-trailing-slashes.js +9 -0
- package/dist/jwt/without-trailing-slashes.js.map +1 -0
- package/dist/jwt.d.ts +3 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +3 -0
- package/dist/jwt.js.map +1 -0
- package/dist/permissions/define-permissions.d.ts +13 -0
- package/dist/permissions/define-permissions.d.ts.map +1 -0
- package/dist/permissions/define-permissions.js +23 -0
- package/dist/permissions/define-permissions.js.map +1 -0
- package/dist/permissions/grant-covers.d.ts +3 -0
- package/dist/permissions/grant-covers.d.ts.map +1 -0
- package/dist/permissions/grant-covers.js +5 -0
- package/dist/permissions/grant-covers.js.map +1 -0
- package/dist/permissions/grant-pattern.d.ts +5 -0
- package/dist/permissions/grant-pattern.d.ts.map +1 -0
- package/dist/permissions/grant-pattern.js +12 -0
- package/dist/permissions/grant-pattern.js.map +1 -0
- package/dist/permissions/grant.d.ts +4 -0
- package/dist/permissions/grant.d.ts.map +1 -0
- package/dist/permissions/grant.js +2 -0
- package/dist/permissions/grant.js.map +1 -0
- package/dist/permissions/is-permission.d.ts +3 -0
- package/dist/permissions/is-permission.d.ts.map +1 -0
- package/dist/permissions/is-permission.js +10 -0
- package/dist/permissions/is-permission.js.map +1 -0
- package/dist/permissions/permission-registry.d.ts +12 -0
- package/dist/permissions/permission-registry.d.ts.map +1 -0
- package/dist/permissions/permission-registry.js +31 -0
- package/dist/permissions/permission-registry.js.map +1 -0
- package/dist/permissions/permission.d.ts +9 -0
- package/dist/permissions/permission.d.ts.map +1 -0
- package/dist/permissions/permission.js +2 -0
- package/dist/permissions/permission.js.map +1 -0
- package/dist/policies/define-policy.d.ts +4 -0
- package/dist/policies/define-policy.d.ts.map +1 -0
- package/dist/policies/define-policy.js +10 -0
- package/dist/policies/define-policy.js.map +1 -0
- package/dist/policies/policy-access.d.ts +32 -0
- package/dist/policies/policy-access.d.ts.map +1 -0
- package/dist/policies/policy-access.js +78 -0
- package/dist/policies/policy-access.js.map +1 -0
- package/dist/policies/policy-context.d.ts +5 -0
- package/dist/policies/policy-context.d.ts.map +1 -0
- package/dist/policies/policy-context.js +2 -0
- package/dist/policies/policy-context.js.map +1 -0
- package/dist/policies/policy.d.ts +18 -0
- package/dist/policies/policy.d.ts.map +1 -0
- package/dist/policies/policy.js +2 -0
- package/dist/policies/policy.js.map +1 -0
- package/dist/rbac/role-based-authorizer.d.ts +35 -0
- package/dist/rbac/role-based-authorizer.d.ts.map +1 -0
- package/dist/rbac/role-based-authorizer.js +68 -0
- package/dist/rbac/role-based-authorizer.js.map +1 -0
- package/dist/rbac/tenant-of-context.d.ts +4 -0
- package/dist/rbac/tenant-of-context.d.ts.map +1 -0
- package/dist/rbac/tenant-of-context.js +6 -0
- package/dist/rbac/tenant-of-context.js.map +1 -0
- package/dist/rebac/define-schema.d.ts +8 -0
- package/dist/rebac/define-schema.d.ts.map +1 -0
- package/dist/rebac/define-schema.js +99 -0
- package/dist/rebac/define-schema.js.map +1 -0
- package/dist/rebac/evaluation.d.ts +31 -0
- package/dist/rebac/evaluation.d.ts.map +1 -0
- package/dist/rebac/evaluation.js +94 -0
- package/dist/rebac/evaluation.js.map +1 -0
- package/dist/rebac/expand-node.d.ts +13 -0
- package/dist/rebac/expand-node.d.ts.map +1 -0
- package/dist/rebac/expand-node.js +2 -0
- package/dist/rebac/expand-node.js.map +1 -0
- package/dist/rebac/in-memory-relation-tuple-store.d.ts +12 -0
- package/dist/rebac/in-memory-relation-tuple-store.d.ts.map +1 -0
- package/dist/rebac/in-memory-relation-tuple-store.js +25 -0
- package/dist/rebac/in-memory-relation-tuple-store.js.map +1 -0
- package/dist/rebac/invalid-tuple-error.d.ts +5 -0
- package/dist/rebac/invalid-tuple-error.d.ts.map +1 -0
- package/dist/rebac/invalid-tuple-error.js +8 -0
- package/dist/rebac/invalid-tuple-error.js.map +1 -0
- package/dist/rebac/object-of.d.ts +3 -0
- package/dist/rebac/object-of.d.ts.map +1 -0
- package/dist/rebac/object-of.js +4 -0
- package/dist/rebac/object-of.js.map +1 -0
- package/dist/rebac/parse-object.d.ts +3 -0
- package/dist/rebac/parse-object.d.ts.map +1 -0
- package/dist/rebac/parse-object.js +10 -0
- package/dist/rebac/parse-object.js.map +1 -0
- package/dist/rebac/parse-subject.d.ts +3 -0
- package/dist/rebac/parse-subject.d.ts.map +1 -0
- package/dist/rebac/parse-subject.js +11 -0
- package/dist/rebac/parse-subject.js.map +1 -0
- package/dist/rebac/permission-mapping.d.ts +3 -0
- package/dist/rebac/permission-mapping.d.ts.map +1 -0
- package/dist/rebac/permission-mapping.js +2 -0
- package/dist/rebac/permission-mapping.js.map +1 -0
- package/dist/rebac/relation-based-authorizer.d.ts +45 -0
- package/dist/rebac/relation-based-authorizer.d.ts.map +1 -0
- package/dist/rebac/relation-based-authorizer.js +113 -0
- package/dist/rebac/relation-based-authorizer.js.map +1 -0
- package/dist/rebac/relation-tuple-store.d.ts +16 -0
- package/dist/rebac/relation-tuple-store.d.ts.map +1 -0
- package/dist/rebac/relation-tuple-store.js +8 -0
- package/dist/rebac/relation-tuple-store.js.map +1 -0
- package/dist/rebac/relation-tuple.d.ts +12 -0
- package/dist/rebac/relation-tuple.d.ts.map +1 -0
- package/dist/rebac/relation-tuple.js +2 -0
- package/dist/rebac/relation-tuple.js.map +1 -0
- package/dist/rebac/resolution.d.ts +9 -0
- package/dist/rebac/resolution.d.ts.map +1 -0
- package/dist/rebac/resolution.js +2 -0
- package/dist/rebac/resolution.js.map +1 -0
- package/dist/rebac/reverse-expansion-limit-error.d.ts +5 -0
- package/dist/rebac/reverse-expansion-limit-error.d.ts.map +1 -0
- package/dist/rebac/reverse-expansion-limit-error.js +8 -0
- package/dist/rebac/reverse-expansion-limit-error.js.map +1 -0
- package/dist/rebac/rewrite.d.ts +24 -0
- package/dist/rebac/rewrite.d.ts.map +1 -0
- package/dist/rebac/rewrite.js +15 -0
- package/dist/rebac/rewrite.js.map +1 -0
- package/dist/rebac/schema-checked-tuple-store.d.ts +14 -0
- package/dist/rebac/schema-checked-tuple-store.d.ts.map +1 -0
- package/dist/rebac/schema-checked-tuple-store.js +25 -0
- package/dist/rebac/schema-checked-tuple-store.js.map +1 -0
- package/dist/rebac/schema-definition.d.ts +3 -0
- package/dist/rebac/schema-definition.d.ts.map +1 -0
- package/dist/rebac/schema-definition.js +2 -0
- package/dist/rebac/schema-definition.js.map +1 -0
- package/dist/rebac/schema-error.d.ts +5 -0
- package/dist/rebac/schema-error.d.ts.map +1 -0
- package/dist/rebac/schema-error.js +8 -0
- package/dist/rebac/schema-error.js.map +1 -0
- package/dist/rebac/schema.d.ts +18 -0
- package/dist/rebac/schema.d.ts.map +1 -0
- package/dist/rebac/schema.js +49 -0
- package/dist/rebac/schema.js.map +1 -0
- package/dist/rebac/subject-ref.d.ts +6 -0
- package/dist/rebac/subject-ref.d.ts.map +1 -0
- package/dist/rebac/subject-ref.js +2 -0
- package/dist/rebac/subject-ref.js.map +1 -0
- package/dist/rebac/tuple-key.d.ts +3 -0
- package/dist/rebac/tuple-key.d.ts.map +1 -0
- package/dist/rebac/tuple-key.js +4 -0
- package/dist/rebac/tuple-key.js.map +1 -0
- package/dist/rebac/type-definition.d.ts +7 -0
- package/dist/rebac/type-definition.d.ts.map +1 -0
- package/dist/rebac/type-definition.js +2 -0
- package/dist/rebac/type-definition.js.map +1 -0
- package/dist/rebac.d.ts +22 -0
- package/dist/rebac.d.ts.map +1 -0
- package/dist/rebac.js +15 -0
- package/dist/rebac.js.map +1 -0
- package/dist/testing/actor-builder.d.ts +21 -0
- package/dist/testing/actor-builder.d.ts.map +1 -0
- package/dist/testing/actor-builder.js +26 -0
- package/dist/testing/actor-builder.js.map +1 -0
- package/dist/testing/allow-all.d.ts +9 -0
- package/dist/testing/allow-all.d.ts.map +1 -0
- package/dist/testing/allow-all.js +11 -0
- package/dist/testing/allow-all.js.map +1 -0
- package/dist/testing/an-actor.d.ts +4 -0
- package/dist/testing/an-actor.d.ts.map +1 -0
- package/dist/testing/an-actor.js +7 -0
- package/dist/testing/an-actor.js.map +1 -0
- package/dist/testing/authorizer-contract.d.ts +18 -0
- package/dist/testing/authorizer-contract.d.ts.map +1 -0
- package/dist/testing/authorizer-contract.js +102 -0
- package/dist/testing/authorizer-contract.js.map +1 -0
- package/dist/testing/deny-all.d.ts +10 -0
- package/dist/testing/deny-all.d.ts.map +1 -0
- package/dist/testing/deny-all.js +14 -0
- package/dist/testing/deny-all.js.map +1 -0
- package/dist/testing/fake-authenticator.d.ts +13 -0
- package/dist/testing/fake-authenticator.d.ts.map +1 -0
- package/dist/testing/fake-authenticator.js +26 -0
- package/dist/testing/fake-authenticator.js.map +1 -0
- package/dist/testing/recording-authorizer.d.ts +15 -0
- package/dist/testing/recording-authorizer.d.ts.map +1 -0
- package/dist/testing/recording-authorizer.js +19 -0
- package/dist/testing/recording-authorizer.js.map +1 -0
- package/dist/testing/relation-tuple-store-contract.d.ts +10 -0
- package/dist/testing/relation-tuple-store-contract.d.ts.map +1 -0
- package/dist/testing/relation-tuple-store-contract.js +68 -0
- package/dist/testing/relation-tuple-store-contract.js.map +1 -0
- package/dist/testing.d.ts +9 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +9 -0
- package/dist/testing.js.map +1 -0
- package/dist/use-cases/authorization-map.d.ts +18 -0
- package/dist/use-cases/authorization-map.d.ts.map +1 -0
- package/dist/use-cases/authorization-map.js +32 -0
- package/dist/use-cases/authorization-map.js.map +1 -0
- package/dist/use-cases/authorization-middleware.d.ts +17 -0
- package/dist/use-cases/authorization-middleware.d.ts.map +1 -0
- package/dist/use-cases/authorization-middleware.js +49 -0
- package/dist/use-cases/authorization-middleware.js.map +1 -0
- package/dist/use-cases/public-use-case.d.ts +5 -0
- package/dist/use-cases/public-use-case.d.ts.map +1 -0
- package/dist/use-cases/public-use-case.js +5 -0
- package/dist/use-cases/public-use-case.js.map +1 -0
- package/dist/use-cases/requires.d.ts +17 -0
- package/dist/use-cases/requires.d.ts.map +1 -0
- package/dist/use-cases/requires.js +14 -0
- package/dist/use-cases/requires.js.map +1 -0
- package/dist/use-cases/use-case-declaration.d.ts +17 -0
- package/dist/use-cases/use-case-declaration.d.ts.map +1 -0
- package/dist/use-cases/use-case-declaration.js +2 -0
- package/dist/use-cases/use-case-declaration.js.map +1 -0
- package/package.json +67 -4
package/README.md
CHANGED
|
@@ -1,3 +1,835 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ddd-ts-kit/auth
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Authentication and authorization behind ports: who is calling, what they may do, and on which
|
|
4
|
+
element, without a role test in a controller or an `if (user.isAdmin)` in the domain. The
|
|
5
|
+
authorization model (roles, attribute policies, relationships in the manner of Google Zanzibar)
|
|
6
|
+
is the project's choice, behind one port.
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
npm i @ddd-ts-kit/auth
|
|
10
|
+
npm i jose # only for @ddd-ts-kit/auth/jwt
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
| Entry | Holds | Imports |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| `@ddd-ts-kit/auth` | permissions, `requires`, `AuthorizationMap`, `authorizationMiddleware`, the ports `Authorizer`, `Access`, `Authenticator` and `SessionAuthenticator`, policies and `PolicyAccess`, `RoleBasedAuthorizer`, `PolicyBasedAuthorizer`, HTTP glue | the kernel only |
|
|
16
|
+
| `@ddd-ts-kit/auth/rebac` | schema DSL, relation tuples, `RelationTupleStore`, `RelationBasedAuthorizer` | the kernel only |
|
|
17
|
+
| `@ddd-ts-kit/auth/jwt` | `JwtAuthenticator`, `keycloakAuthenticator` | `jose` (optional peer), the only file that does |
|
|
18
|
+
| `@ddd-ts-kit/auth/di` | `authorizationModule`, `authorizationMapOf`, and again the classes the module binds: `Authorizer`, `Authenticator`, `Access`, `AuthorizationMap` | `@ddd-ts-kit/di` (optional peer) |
|
|
19
|
+
| `@ddd-ts-kit/auth/testing` | `anActor()`, `AllowAll`, `DenyAll`, `RecordingAuthorizer`, `FakeAuthenticator`, the two contract suites | `@ddd-ts-kit/testing` (optional peer), the runner you pass |
|
|
20
|
+
|
|
21
|
+
The actor model lives in `@ddd-ts-kit/kernel`: `Actor`, `anonymousActor()`, `systemActor(name)`,
|
|
22
|
+
`AuthorizedCommand`, `AuthorizedQuery`, the `ActorContext` port and its two implementations.
|
|
23
|
+
|
|
24
|
+
## Three responsibilities, three places
|
|
25
|
+
|
|
26
|
+
| Responsibility | Question | Where it lives | With what |
|
|
27
|
+
|---|---|---|---|
|
|
28
|
+
| Authentication | who is calling? | the platform, at the boundary (`src/platform/auth`, routes, consumers) | an adapter of `Authenticator` verifies the credential and produces an `Actor`, and a `SessionAuthenticator` the whole `Session`: plain data, never the token |
|
|
29
|
+
| Authorization | may this actor do this, on this? | the application layer of each context (`application/authorization/`) | permissions declared as data, one requirement per use case checked by a bus middleware, policies for rules that need loaded state, all through the `Authorizer` port |
|
|
30
|
+
| Invariants | is this a valid state of the business? | the domain | aggregates and `DomainError`s. "An invoice cannot be paid twice" is not a permission |
|
|
31
|
+
|
|
32
|
+
Nothing is reachable by default. A command or a query that declares neither a permission nor
|
|
33
|
+
that it is public is refused, at run time by the middleware and, when you ask for it, when the
|
|
34
|
+
application is created.
|
|
35
|
+
|
|
36
|
+
## A complete example
|
|
37
|
+
|
|
38
|
+
### 1. Permissions, declared by the context that owns them
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
// contexts/billing/application/authorization/billing.permissions.ts
|
|
42
|
+
import { definePermissions } from '@ddd-ts-kit/auth';
|
|
43
|
+
|
|
44
|
+
export const billingPermissions = definePermissions('billing', {
|
|
45
|
+
'invoice.issue': { description: 'Issue an invoice to a customer' },
|
|
46
|
+
'invoice.read': { description: 'Read invoices' },
|
|
47
|
+
'invoice.void': { description: 'Void an issued invoice' },
|
|
48
|
+
});
|
|
49
|
+
// billingPermissions['invoice.issue'].id === 'billing:invoice.issue', typed as that literal
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`PermissionRegistry.of(billingPermissions, catalogPermissions)` lists them all (`all()`,
|
|
53
|
+
`contexts()`, `inContext(name)`, `find(id)`): documentation and administration screens are
|
|
54
|
+
generated from it, never written by hand.
|
|
55
|
+
|
|
56
|
+
### 2. Use cases that carry their actor
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
// contexts/billing/application/commands/void-invoice/void-invoice.command.ts
|
|
60
|
+
import { AuthorizedCommand, type Actor } from '@ddd-ts-kit/kernel';
|
|
61
|
+
|
|
62
|
+
export class VoidInvoice extends AuthorizedCommand {
|
|
63
|
+
constructor(
|
|
64
|
+
actor: Actor,
|
|
65
|
+
readonly invoiceId: string,
|
|
66
|
+
) {
|
|
67
|
+
super(actor);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The actor is the first constructor argument: a call site cannot forget it, and the compiler
|
|
73
|
+
refuses `requires` on a message that has none. `Command` and `Query` stay for public use cases.
|
|
74
|
+
|
|
75
|
+
### 3. The requirement, on the message or next to the handler registration
|
|
76
|
+
|
|
77
|
+
A project generated by the CLI uses the declared style by default: the requirement is written
|
|
78
|
+
on the message with `@Requires` or `@Public`, shown below, and there is no module file. The
|
|
79
|
+
explicit style writes it in `<context>.module.ts`:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
// contexts/billing/billing.module.ts
|
|
83
|
+
import { Access } from '@ddd-ts-kit/auth';
|
|
84
|
+
import { defineContextModule } from '@ddd-ts-kit/di';
|
|
85
|
+
import { Clock, IdGenerator } from '@ddd-ts-kit/kernel';
|
|
86
|
+
|
|
87
|
+
export const billingModule = defineContextModule({
|
|
88
|
+
name: 'billing',
|
|
89
|
+
register(builder) {
|
|
90
|
+
builder
|
|
91
|
+
.command(IssueInvoice)
|
|
92
|
+
.requires(billingPermissions['invoice.issue'])
|
|
93
|
+
.handledBy(IssueInvoiceHandler, [InvoiceRepository, IdGenerator]);
|
|
94
|
+
builder
|
|
95
|
+
.command(VoidInvoice)
|
|
96
|
+
.requires(billingPermissions['invoice.void'], {
|
|
97
|
+
resource: (command) => ({ type: 'invoice', id: command.invoiceId }),
|
|
98
|
+
})
|
|
99
|
+
.handledBy(VoidInvoiceHandler, [InvoiceRepository, Access, Clock]);
|
|
100
|
+
builder.query(GetCatalog).public().handledBy(GetCatalogHandler, [Catalog]);
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The handler is not touched. `resource` names the element the right bears on, `context` passes
|
|
106
|
+
facts of the message the authorizer may need (the tenant it targets).
|
|
107
|
+
|
|
108
|
+
The same requirement said on the message itself, with the declarations of the kernel, for a
|
|
109
|
+
declared context:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
// contexts/billing/application/commands/void-invoice/void-invoice.command.ts
|
|
113
|
+
import { Requires } from '@ddd-ts-kit/kernel/declarations';
|
|
114
|
+
|
|
115
|
+
@Requires(billingPermissions['invoice.void'], {
|
|
116
|
+
resource: (command: VoidInvoice) => ({ type: 'invoice', id: command.invoiceId }),
|
|
117
|
+
})
|
|
118
|
+
export class VoidInvoice extends AuthorizedCommand { /* ... */ }
|
|
119
|
+
|
|
120
|
+
// void-invoice.handler.ts: Access comes from '@ddd-ts-kit/auth', which the application layer may import
|
|
121
|
+
@CommandHandler(VoidInvoice, [InvoiceRepository, Access, Clock])
|
|
122
|
+
export class VoidInvoiceHandler implements CommandHandler<VoidInvoice> { /* ... */ }
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`@Public()` is the counterpart of `.public()`. Both forms feed the same authorization map and
|
|
126
|
+
the same `authorization: 'required'` check; written on the builder, a requirement wins over
|
|
127
|
+
the one of the message. A policy class declared with
|
|
128
|
+
`@Policy(permission, { name: 'invoice.only-issuer-voids' })` (an `allows(actor, subject, { now })`
|
|
129
|
+
method, and `appliesTo(subject)` when it must tell its subjects apart) is evaluated by
|
|
130
|
+
`Access` next to the policies given to `authorizationModule`.
|
|
131
|
+
|
|
132
|
+
The ports of this package are abstract classes, and the class is what a dependency list, a
|
|
133
|
+
binding or a test names: `Authorizer`, `Access`, `Authenticator`, `SessionAuthenticator` and
|
|
134
|
+
`ResourceAttributes` from `@ddd-ts-kit/auth`, `RelationTupleStore` from
|
|
135
|
+
`@ddd-ts-kit/auth/rebac`. An adapter `implements` its port and never extends it; a plain
|
|
136
|
+
object with the same methods stands for it in a test.
|
|
137
|
+
|
|
138
|
+
### 4. A policy, for a rule that needs the loaded element
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
// contexts/billing/application/authorization/only-issuer-voids-same-day.policy.ts
|
|
142
|
+
import { definePolicy } from '@ddd-ts-kit/auth';
|
|
143
|
+
|
|
144
|
+
export const onlyIssuerVoidsSameDay = definePolicy<Invoice>({
|
|
145
|
+
name: 'invoice.only-issuer-voids-same-day',
|
|
146
|
+
permission: billingPermissions['invoice.void'],
|
|
147
|
+
appliesTo: (subject): subject is Invoice => subject instanceof Invoice,
|
|
148
|
+
allows: (actor, invoice, { now }) => actor.id === invoice.issuedBy && isSameDay(invoice.issuedAt, now),
|
|
149
|
+
});
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
// contexts/billing/application/commands/void-invoice/void-invoice.handler.ts
|
|
154
|
+
export class VoidInvoiceHandler {
|
|
155
|
+
constructor(
|
|
156
|
+
private readonly invoices: InvoiceRepository,
|
|
157
|
+
private readonly access: Access,
|
|
158
|
+
private readonly clock: Clock,
|
|
159
|
+
) {}
|
|
160
|
+
|
|
161
|
+
async execute(command: VoidInvoice): Promise<void> {
|
|
162
|
+
const invoice = await this.invoices.findById(command.invoiceId);
|
|
163
|
+
if (invoice === undefined) {
|
|
164
|
+
throw new NotFoundError('Invoice not found');
|
|
165
|
+
}
|
|
166
|
+
await this.access.authorize(command.actor, billingPermissions['invoice.void'], invoice);
|
|
167
|
+
invoice.void(this.clock.now());
|
|
168
|
+
await this.invoices.save(invoice);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`Access.authorize` asks every policy of the permission, then the authorizer, and raises
|
|
174
|
+
`ForbiddenError` with a reason code: the name of the policy that answered `false`, or the code
|
|
175
|
+
the policy or the authorizer gave. A policy is a plain object: its spec calls
|
|
176
|
+
`onlyIssuerVoidsSameDay.allows(actor, invoice, { now })` and needs nothing else.
|
|
177
|
+
|
|
178
|
+
`Access` is the port the handler depends on; `PolicyAccess` is its implementation, which
|
|
179
|
+
`authorizationModule` binds. `PolicyAccessOptions.resourceOf` says how a subject names its
|
|
180
|
+
resource (`invoice => ({ type: 'invoice', id: invoice.id })`) when the authorizer decides per
|
|
181
|
+
element. By default only a plain `{ type, id }` is taken as a resource reference.
|
|
182
|
+
|
|
183
|
+
### 5. The composition root
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
// main.ts
|
|
187
|
+
import { createApplication, kernelModule } from '@ddd-ts-kit/di';
|
|
188
|
+
import { RoleBasedAuthorizer } from '@ddd-ts-kit/auth';
|
|
189
|
+
import { authorizationModule } from '@ddd-ts-kit/auth/di';
|
|
190
|
+
import { JwtAuthenticator } from '@ddd-ts-kit/auth/jwt';
|
|
191
|
+
|
|
192
|
+
const app = createApplication({
|
|
193
|
+
modules: [
|
|
194
|
+
kernelModule(),
|
|
195
|
+
billingModule,
|
|
196
|
+
authorizationModule({
|
|
197
|
+
authorizer: new RoleBasedAuthorizer({
|
|
198
|
+
roles: {
|
|
199
|
+
admin: ['*'],
|
|
200
|
+
accountant: [billingPermissions['invoice.issue'], billingPermissions['invoice.read']],
|
|
201
|
+
controller: ['billing:*'],
|
|
202
|
+
},
|
|
203
|
+
inherits: { controller: ['accountant'] },
|
|
204
|
+
}),
|
|
205
|
+
authenticator: new JwtAuthenticator({
|
|
206
|
+
jwksUri: config.oidc.jwksUri,
|
|
207
|
+
issuer: config.oidc.issuer,
|
|
208
|
+
audience: 'billing-api',
|
|
209
|
+
}),
|
|
210
|
+
policies: [onlyIssuerVoidsSameDay],
|
|
211
|
+
onDecision: (record) => auditTrail.append(record),
|
|
212
|
+
}),
|
|
213
|
+
],
|
|
214
|
+
authorization: 'required',
|
|
215
|
+
});
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
The module binds `Authorizer`, `Access` (a `PolicyAccess` over the authorizer, the policies and
|
|
219
|
+
the `Clock` of the application), the `AuthorizationMap` built from what the use cases declared
|
|
220
|
+
and, when one is given, `Authenticator`. Each is resolved by its class:
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
const authorizer = app.resolve(Authorizer);
|
|
224
|
+
const declared = app.resolve(AuthorizationMap).declarations(); // for documentation and administration screens
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
An authenticator that has dependencies of its own is bound by a module of the platform
|
|
228
|
+
instead of being given as an option:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
// platform/auth/auth.module.ts
|
|
232
|
+
import { Authenticator } from '@ddd-ts-kit/auth';
|
|
233
|
+
import { defineContextModule } from '@ddd-ts-kit/di';
|
|
234
|
+
|
|
235
|
+
export const authModule = defineContextModule({
|
|
236
|
+
name: 'auth',
|
|
237
|
+
register(builder) {
|
|
238
|
+
builder.bind(Authenticator).toClass(SessionCookieAuthenticator, [SessionStore]);
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
`authorizationModule` binds the guard that the command and query buses of `kernelModule()` run
|
|
244
|
+
before anything else, the unit of work included: a refused command opens no transaction. A bus
|
|
245
|
+
of your own, given through `kernelModule({ commandBus })`, has to run the guard itself:
|
|
246
|
+
`.use((message, next, info) => resolve(UseCaseGuard).check(message, next, info))`, `UseCaseGuard`
|
|
247
|
+
being the port of `@ddd-ts-kit/di` the module binds.
|
|
248
|
+
`authorization: 'required'` makes `createApplication` throw a `WiringError`
|
|
249
|
+
(`UNDECLARED_AUTHORIZATION`) naming every command and query that has neither `.requires(...)`
|
|
250
|
+
nor `.public()`, and another (`AUTHORIZATION_NOT_ENFORCED`) when no module binds a guard. The
|
|
251
|
+
default is `'optional'` for now, so existing modules keep working; with it, an undeclared use
|
|
252
|
+
case is still refused when it is sent, with `UndeclaredUseCaseError`, as soon as
|
|
253
|
+
`authorizationModule` is listed.
|
|
254
|
+
|
|
255
|
+
`onDecision` is the audit hook. It receives `{ actorId, permissionId, resource?, decision,
|
|
256
|
+
useCase?, kind? }` (`kind` is `command` or `query`, as the bus says) for every decision, allowed or refused, and a hook that throws refuses the request:
|
|
257
|
+
no decision goes unrecorded.
|
|
258
|
+
|
|
259
|
+
Without `@ddd-ts-kit/di`, the same pieces are assembled by hand:
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
const map = AuthorizationMap.of(
|
|
263
|
+
requires(IssueInvoice, billingPermissions['invoice.issue']),
|
|
264
|
+
requires(VoidInvoice, billingPermissions['invoice.void'], {
|
|
265
|
+
resource: (command) => ({ type: 'invoice', id: command.invoiceId }),
|
|
266
|
+
}),
|
|
267
|
+
publicUseCase(GetCatalog),
|
|
268
|
+
);
|
|
269
|
+
const guard = authorizationMiddleware({ authorizer, map, onDecision });
|
|
270
|
+
const commandBus = new InMemoryCommandBus().use(guard).use(unitOfWorkMiddleware(unitOfWork));
|
|
271
|
+
const queryBus = new InMemoryQueryBus().use(guard);
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### 6. A route
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
// contexts/billing/presentation/http/void-invoice.route.ts
|
|
278
|
+
import { actorOf } from '@ddd-ts-kit/auth';
|
|
279
|
+
|
|
280
|
+
export const voidInvoiceRoute = (commandBus: CommandBus) =>
|
|
281
|
+
defineRoute(
|
|
282
|
+
{
|
|
283
|
+
method: 'POST',
|
|
284
|
+
path: '/invoices/:invoiceId/void',
|
|
285
|
+
operationId: 'voidInvoice',
|
|
286
|
+
summary: 'Void an invoice',
|
|
287
|
+
responses: { 204: { description: 'Voided' } },
|
|
288
|
+
errors: ['UNAUTHORIZED', 'FORBIDDEN', 'NOT_FOUND'],
|
|
289
|
+
security: ['bearer'],
|
|
290
|
+
},
|
|
291
|
+
async ({ params, context }) => {
|
|
292
|
+
await commandBus.execute(new VoidInvoice(actorOf(context.principal), params.invoiceId));
|
|
293
|
+
return { status: 204 };
|
|
294
|
+
},
|
|
295
|
+
);
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
// platform/http/server.ts
|
|
300
|
+
import { Authenticator, principalResolver } from '@ddd-ts-kit/auth';
|
|
301
|
+
import { ExecutionScope } from '@ddd-ts-kit/kernel';
|
|
302
|
+
|
|
303
|
+
const options = {
|
|
304
|
+
resolvePrincipal: principalResolver(app.resolve(Authenticator)), // answers a Session
|
|
305
|
+
executionScope: app.resolve(ExecutionScope),
|
|
306
|
+
};
|
|
307
|
+
// passed to the adapter of @ddd-ts-kit/http: mountExpress(server, routes, options)
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
The route maps a request to a command and nothing else. It contains no role, no ownership
|
|
311
|
+
test and no status code for a refusal: `UnauthenticatedError` and `InvalidCredentialError`
|
|
312
|
+
carry the code `UNAUTHORIZED`, `ForbiddenError` carries `FORBIDDEN`, the codes the default
|
|
313
|
+
error mapping of `@ddd-ts-kit/http` answers with 401 and 403.
|
|
314
|
+
|
|
315
|
+
`principalResolver` reads `Authorization: Bearer <token>` from the framework request; a header
|
|
316
|
+
that is present and is not a well-formed bearer credential is refused, never treated as an
|
|
317
|
+
anonymous call. It answers a kernel `Session` (the anonymous session without credential), which
|
|
318
|
+
the pipeline puts, with its actor, in the execution context of the request, and which the
|
|
319
|
+
`executionScope` option makes ambient for infrastructure: `ActorContext` of `kernelModule()`
|
|
320
|
+
reads its actor from that scope (see "The current actor"). An integration event handler and a
|
|
321
|
+
scheduled job open nothing themselves: `@ddd-ts-kit/di` runs each event in an execution
|
|
322
|
+
context of origin `event` whose actor is `systemActor('<consumer name>')`, and the scheduler
|
|
323
|
+
runs each job in one of origin `schedule` whose actor is `systemActor('<context>.<job-name>')`.
|
|
324
|
+
The handler or the job puts that same actor in the command it sends:
|
|
325
|
+
`commands.execute(new SendReminders(systemActor('billing.send-reminders'), execution.scheduledAt))`.
|
|
326
|
+
|
|
327
|
+
| Reading | Gives |
|
|
328
|
+
|---|---|
|
|
329
|
+
| `actorOf(context.principal)` | the actor, anonymous when there is none; also accepts the context itself |
|
|
330
|
+
| `sessionOf(context)` | the session: who, since when, until when, how it was authenticated, `impersonatedBy` |
|
|
331
|
+
| `context.execution` | the whole execution context: correlation id, tenant, locale, deadline |
|
|
332
|
+
|
|
333
|
+
`inActorContext(actorContext, handler)` keeps working for a project that has an `ActorContext`
|
|
334
|
+
and no execution scope: it runs the handler inside `ActorContext.run`.
|
|
335
|
+
|
|
336
|
+
## Authentication
|
|
337
|
+
|
|
338
|
+
### Actors and sessions
|
|
339
|
+
|
|
340
|
+
`Authenticator` answers an `Actor`. `SessionAuthenticator` extends it with
|
|
341
|
+
`authenticateSession(credential): Promise<Session>`, for an adapter whose credential knows more
|
|
342
|
+
than who: the session id, when it was issued and when it expires, the method and strength of
|
|
343
|
+
the authentication, who is impersonating. A separate port rather than a required method, so an
|
|
344
|
+
adapter that only knows actors (an API key table, a development header) stays three lines; the
|
|
345
|
+
boundary does not care which one it gets:
|
|
346
|
+
|
|
347
|
+
```ts
|
|
348
|
+
import { resolveSession } from '@ddd-ts-kit/auth';
|
|
349
|
+
|
|
350
|
+
const session = await resolveSession(authenticator, credential);
|
|
351
|
+
// a SessionAuthenticator answers its own session; for a plain Authenticator the session is
|
|
352
|
+
// derived from the actor and says only what is known: id of the actor as session id, no
|
|
353
|
+
// issue instant (the epoch), no expiry, method `token`
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
### JSON Web Tokens
|
|
357
|
+
|
|
358
|
+
`JwtAuthenticator` verifies the signature, the issuer, the audience, the expiry and the
|
|
359
|
+
algorithm (asymmetric families only unless told otherwise; `alg: none` is always refused), and
|
|
360
|
+
is a `SessionAuthenticator`. Nothing of the token is kept in the actor or in the session. The
|
|
361
|
+
verification key, which may be a shared secret, is not a property of the authenticator:
|
|
362
|
+
logging or serializing the object shows no key.
|
|
363
|
+
|
|
364
|
+
| Field of the session | Claim |
|
|
365
|
+
|---|---|
|
|
366
|
+
| `id` | `sid`, else `jti`, else the subject and the issue instant |
|
|
367
|
+
| `issuedAt`, `expiresAt` | `iat`, `exp` |
|
|
368
|
+
| `authenticationMethod` | `password` when `amr` lists `pwd`, else `token`; or the `authenticationMethod` option, a value or a function of the claims |
|
|
369
|
+
| `authenticationStrength` | `mfa` when `amr` lists `mfa` (RFC 8176), `assuranceLevel` from `acr` |
|
|
370
|
+
| `impersonatedBy` | the `sub` of the `act` claim (RFC 8693), or the claim named by `impersonatorClaim`: the id itself, or an object holding it under `sub` or `id` |
|
|
371
|
+
| `locale`, `timeZone` | `locale`, `zoneinfo` |
|
|
372
|
+
| `extensions` | what `toSessionExtensions(claims)` answers |
|
|
373
|
+
|
|
374
|
+
```ts
|
|
375
|
+
import { JwtAuthenticator } from '@ddd-ts-kit/auth/jwt';
|
|
376
|
+
|
|
377
|
+
new JwtAuthenticator({
|
|
378
|
+
jwksUri: 'https://id.example.com/realms/shop/protocol/openid-connect/certs', // or key: publicKey
|
|
379
|
+
issuer: 'https://id.example.com/realms/shop',
|
|
380
|
+
audience: 'shop-api',
|
|
381
|
+
algorithms: ['RS256'], // default: RS256, PS256, ES256, EdDSA
|
|
382
|
+
clockTolerance: 5, // seconds, default 0
|
|
383
|
+
tenantClaim: 'tenant_id', // the default
|
|
384
|
+
toActor: (claims) => actor, // replaces the default mapping
|
|
385
|
+
});
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
`authenticate(undefined)` answers the anonymous actor. A token that is expired, not valid yet,
|
|
389
|
+
for another audience or issuer, signed with another key, altered, unsigned (`alg: none`), or
|
|
390
|
+
using an algorithm outside the allow-list raises `InvalidCredentialError` (code `UNAUTHORIZED`)
|
|
391
|
+
with a reason code: `TOKEN_EXPIRED`, `CLAIM_MISMATCH`, `BAD_SIGNATURE`, `ALGORITHM_NOT_ALLOWED`,
|
|
392
|
+
`UNKNOWN_KEY`, `MALFORMED_TOKEN`, `UNSUPPORTED_TOKEN`, `NO_SUBJECT`. A key set that cannot be
|
|
393
|
+
fetched is not a refusal: the error is left as it is and answered as a 5xx.
|
|
394
|
+
|
|
395
|
+
The default mapping for OIDC: `id` from `sub` (required), `type: 'user'`, `tenantId` from the
|
|
396
|
+
tenant claim when it is a string, no attribute, and `roles` from the first claim present among
|
|
397
|
+
`realm_access.roles` (realm roles), `roles` (a list or space-separated words) and `scope`. They
|
|
398
|
+
are never merged: a scope a client asked for must not pass for a role. The actor keeps nothing
|
|
399
|
+
of the token. Map service accounts to `type: 'service'` with `toActor`.
|
|
400
|
+
|
|
401
|
+
### Keycloak
|
|
402
|
+
|
|
403
|
+
```ts
|
|
404
|
+
import { keycloakAuthenticator } from '@ddd-ts-kit/auth/jwt';
|
|
405
|
+
|
|
406
|
+
const authenticator = keycloakAuthenticator({
|
|
407
|
+
baseUrl: 'https://id.example.com', // without the realm
|
|
408
|
+
realm: 'shop',
|
|
409
|
+
audience: 'shop-api', // what the `aud` claim must name
|
|
410
|
+
clientId: 'shop-api', // optional: its client roles join the realm roles
|
|
411
|
+
tenantClaim: 'organization', // optional, default `tenant_id`
|
|
412
|
+
});
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
It derives the issuer (`<baseUrl>/realms/<realm>`) and the key set
|
|
416
|
+
(`<issuer>/protocol/openid-connect/certs`, fetched and cached, rotated keys followed), and maps
|
|
417
|
+
an access token as follows:
|
|
418
|
+
|
|
419
|
+
| Field | Claim |
|
|
420
|
+
|---|---|
|
|
421
|
+
| `actor.id` | `sub` |
|
|
422
|
+
| `actor.type` | `service` when `preferred_username` starts with `service-account-` (a client using the client credentials grant), else `user` |
|
|
423
|
+
| `actor.roles` | `realm_access.roles`, plus `resource_access.<clientId>.roles` when `clientId` is given, each once |
|
|
424
|
+
| `actor.attributes` | `username` from `preferred_username`, `email`, `groups` from the `groups` claim (or `groupsClaim`) |
|
|
425
|
+
| `actor.tenantId` | the claim named by `tenantClaim`: a text claim, or the `organization` claim (a list of aliases, or an object keyed by alias) when it names exactly one organization |
|
|
426
|
+
| `session.id` | `sid`, the session of the realm; `jti` for a service account, which has none |
|
|
427
|
+
| `session.authenticationMethod` | `sso` for a person, `service` for a service account |
|
|
428
|
+
| `session.authenticationStrength` | `assuranceLevel` from `acr` |
|
|
429
|
+
| `session.impersonatedBy` | the claim named by `impersonatorClaim` (default `act`) |
|
|
430
|
+
|
|
431
|
+
What to configure in the realm, for these claims to be in the access token:
|
|
432
|
+
|
|
433
|
+
1. **A client for the API** (`shop-api`): client authentication on, no flow enabled (it only
|
|
434
|
+
receives tokens: what used to be called bearer-only). It holds the client roles, if you use
|
|
435
|
+
them.
|
|
436
|
+
2. **An audience mapper.** By default the `aud` of an access token does not name your API (it
|
|
437
|
+
is `account`, or absent), and the token is refused with `CLAIM_MISMATCH`. On the client the
|
|
438
|
+
users sign in with (the front end), or in a client scope it has by default: add a mapper of
|
|
439
|
+
type *Audience*, *Included Client Audience* `shop-api`, *Add to access token* on.
|
|
440
|
+
3. **The roles mappers.** The built-in client scope `roles`, a default scope of every client,
|
|
441
|
+
already writes `realm_access.roles` (mapper *realm roles*) and
|
|
442
|
+
`resource_access.<client>.roles` (mapper *client roles*). Check that the front-end client
|
|
443
|
+
kept it, and that *Full scope allowed* is on, or that the roles you expect are in its scope
|
|
444
|
+
mappings: Keycloak only writes the roles in the scope of the client that asked for the token.
|
|
445
|
+
4. **Groups**, if you authorize on them: a mapper of type *Group Membership*, *Token Claim Name*
|
|
446
|
+
`groups`, *Add to access token* on. *Full group path* decides between `/finance/europe`
|
|
447
|
+
and `europe`; the attribute holds what the mapper writes.
|
|
448
|
+
5. **The tenant**, one of: a mapper of type *User Attribute* writing `tenant_id` from an
|
|
449
|
+
attribute of the user; or, with Organizations enabled in the realm, the client scope
|
|
450
|
+
`organization` requested by the front end, and `tenantClaim: 'organization'`. A user who
|
|
451
|
+
belongs to several organizations gets no tenant from that claim: choosing one is a decision
|
|
452
|
+
of your project, to take in `toSessionExtensions` or in a mapper.
|
|
453
|
+
6. **Impersonation**, if support staff impersonate users from the admin console: Keycloak
|
|
454
|
+
keeps the administrator in the session notes of the impersonated session. A mapper of type
|
|
455
|
+
*User Session Note*, *User Session Note* `IMPERSONATOR_ID`, *Token Claim Name*
|
|
456
|
+
`impersonator_id`, with `impersonatorClaim: 'impersonator_id'`, puts it in
|
|
457
|
+
`session.impersonatedBy`, which `logFieldsOf` carries to every log line.
|
|
458
|
+
|
|
459
|
+
`preferred_username` and `email` come from the built-in `profile` and `email` scopes. The
|
|
460
|
+
preset never calls Keycloak beyond the key set: a revoked session is refused when its access
|
|
461
|
+
token expires, so keep the access token lifespan short.
|
|
462
|
+
|
|
463
|
+
## Choosing a model
|
|
464
|
+
|
|
465
|
+
The three authorizers implement the same `Authorizer` port and pass the same contract suite.
|
|
466
|
+
|
|
467
|
+
| | Roles (RBAC) | Attribute policies (ABAC) | Relationships (ReBAC) |
|
|
468
|
+
|---|---|---|---|
|
|
469
|
+
| Class | `RoleBasedAuthorizer` | `PolicyBasedAuthorizer` | `RelationBasedAuthorizer` (`/rebac`) |
|
|
470
|
+
| The question it answers | does one of the actor's roles grant this action? | do the attributes of the actor, the resource and the moment satisfy a rule? | is there a path of stored relationships from this element to the actor? |
|
|
471
|
+
| Choose it when | rights follow job functions and bear on actions, the same for every element | rights depend on properties: department, amount, status, time of day, with explicit refusals | rights are per element and shared: ownership, teams, folders, "whoever can edit the parent" |
|
|
472
|
+
| Granularity | action, plus a tenant check | element, through attributes you supply | element, by construction |
|
|
473
|
+
| Data to maintain | a role table in code, roles in the token | a `ResourceAttributes` port (projection or cache) | relation tuples, written when aggregates are created or shared |
|
|
474
|
+
| Cost of a check | constant, no I/O | one attribute read | several tuple reads, bounded by a depth limit |
|
|
475
|
+
| Listing (`listAccessible`) | `all`, `none`, or a tenant `filter`: constant | not available: a predicate is not a query | `ids`, by reverse expansion: grows with what the actor can reach |
|
|
476
|
+
| Typical denial reasons | `NO_MATCHING_ROLE`, `TENANT_MISMATCH` | `NO_MATCHING_POLICY`, `EXPLICIT_DENY` | `NO_RELATION`, `RESOURCE_REQUIRED`, `UNMAPPED_PERMISSION`, `DEPTH_EXCEEDED` |
|
|
477
|
+
|
|
478
|
+
**What changes when you switch.** The adapter given to `authorizationModule`, and the data it
|
|
479
|
+
needs. From RBAC to ReBAC: add `resource:` to the `requires` of the use cases that did not name
|
|
480
|
+
one (a relationship is always to something), map each permission to a schema permission, and
|
|
481
|
+
start writing tuples. Permissions, commands, queries, handlers, policies, routes and tests of
|
|
482
|
+
handlers do not change. Models can also be layered: `Access` asks the instance policies and
|
|
483
|
+
then the authorizer, so "roles for actions, policies for the few per-element rules" needs no
|
|
484
|
+
tuple store at all, and is the place to start.
|
|
485
|
+
|
|
486
|
+
Every authorizer refuses the anonymous actor (`ANONYMOUS`), and an actor of type `system` has no
|
|
487
|
+
right unless the model gives it one.
|
|
488
|
+
|
|
489
|
+
### Roles
|
|
490
|
+
|
|
491
|
+
```ts
|
|
492
|
+
new RoleBasedAuthorizer({
|
|
493
|
+
roles: { admin: ['*'], controller: ['billing:*'], clerk: [billingPermissions['invoice.read']] },
|
|
494
|
+
inherits: { controller: ['clerk'] }, // a controller has all a clerk has
|
|
495
|
+
tenantOf: tenantOfContext, // turns the tenant check on
|
|
496
|
+
crossTenantRoles: ['admin'], // may act in every tenant
|
|
497
|
+
systemRole: 'automation', // the role of systemActor(...), none by default
|
|
498
|
+
});
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
A grant is a permission, its id, `<context>:*` or `*`. A grant that could never match, an
|
|
502
|
+
unknown inherited role and a cycle in the hierarchy are refused at construction. With
|
|
503
|
+
`tenantOf`, a request that targets another tenant than the actor's is refused; the tenant is
|
|
504
|
+
read from the `context` of the requirement: `requires(X, permission, { context: (command) =>
|
|
505
|
+
({ tenantId: command.tenantId }) })`.
|
|
506
|
+
|
|
507
|
+
### Attribute policies
|
|
508
|
+
|
|
509
|
+
```ts
|
|
510
|
+
const sameDepartment: Condition = ({ actor, attributes }) => actor.attributes['department'] === attributes['department'];
|
|
511
|
+
const isLarge: Condition = ({ attributes }) => Number(attributes['amount']) > 10_000;
|
|
512
|
+
|
|
513
|
+
new PolicyBasedAuthorizer({
|
|
514
|
+
clock, // time comes from the Clock port
|
|
515
|
+
attributes: invoiceAttributes, // ResourceAttributes: of(resource) => attributes
|
|
516
|
+
policies: [
|
|
517
|
+
permit('invoice.read-own-department', [billingPermissions['invoice.read']], sameDepartment),
|
|
518
|
+
permit('invoice.managers-void', ['billing:invoice.void'], ({ actor }) => actor.attributes['manager'] === true),
|
|
519
|
+
forbid('invoice.no-large-void', [billingPermissions['invoice.void']], allOf(isLarge, not(secondApproval))),
|
|
520
|
+
],
|
|
521
|
+
});
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
A matching `forbid` wins over every `permit`; with no matching `permit` the answer is no.
|
|
525
|
+
Conditions compose with `anyOf`, `allOf` and `not`, and see `{ actor, permission, resource?,
|
|
526
|
+
attributes, context, now }`.
|
|
527
|
+
|
|
528
|
+
### Relationships
|
|
529
|
+
|
|
530
|
+
```ts
|
|
531
|
+
import { defineSchema, union, intersection, exclusion, from } from '@ddd-ts-kit/auth/rebac';
|
|
532
|
+
|
|
533
|
+
const schema = defineSchema({
|
|
534
|
+
user: {},
|
|
535
|
+
group: { relations: { member: ['user', 'group#member'] } },
|
|
536
|
+
folder: {
|
|
537
|
+
relations: { owner: ['user'], viewer: ['user', 'group#member'], parent: ['folder'] },
|
|
538
|
+
permissions: {
|
|
539
|
+
edit: union('owner', from('parent', 'edit')),
|
|
540
|
+
view: union('edit', 'viewer', from('parent', 'view')),
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
document: {
|
|
544
|
+
relations: { owner: ['user'], editor: ['user', 'group#member'], banned: ['user'], parent: ['folder'] },
|
|
545
|
+
permissions: {
|
|
546
|
+
edit: exclusion(union('owner', 'editor', from('parent', 'edit')), 'banned'),
|
|
547
|
+
view: union('edit', from('parent', 'view')),
|
|
548
|
+
},
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
const authorizer = new RelationBasedAuthorizer({
|
|
553
|
+
schema,
|
|
554
|
+
store: tupleStore,
|
|
555
|
+
mapping: {
|
|
556
|
+
document: { [docsPermissions['document.edit'].id]: 'edit', [docsPermissions['document.view'].id]: 'view' },
|
|
557
|
+
folder: { [docsPermissions['folder.view'].id]: 'view' },
|
|
558
|
+
},
|
|
559
|
+
});
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
| Zanzibar concept | Here |
|
|
563
|
+
|---|---|
|
|
564
|
+
| relation tuple `object#relation@subject` | `{ object: 'document:1', relation: 'editor', subject: 'user:ada' }` |
|
|
565
|
+
| userset as a subject | `subject: 'group:eng#member'`, declared as `'group#member'` in the relation |
|
|
566
|
+
| computed userset | a name in a rewrite: `union('owner', 'editor')`, `view: union('edit', ...)` |
|
|
567
|
+
| tuple to userset | `from('parent', 'edit')`: whoever may edit the parent |
|
|
568
|
+
| union, intersection, exclusion | `union(...)`, `intersection(...)`, `exclusion(base, subtract)` |
|
|
569
|
+
| Check | `authorizer.check({ actor, permission, resource })` |
|
|
570
|
+
| Expand | `authorizer.expand({ actor, permission, resource })` |
|
|
571
|
+
| reverse lookup | `authorizer.listAccessible({ actor, permission, resourceType })` |
|
|
572
|
+
|
|
573
|
+
`defineSchema` validates when the schema is defined: an unknown type, a userset of a relation
|
|
574
|
+
that does not exist, a rewrite naming something undeclared, a `from` over something that is not
|
|
575
|
+
a relation to objects or towards a type lacking the permission, one name used for a relation
|
|
576
|
+
and a permission, an empty union or intersection, and a rewrite that reads no tuple and could
|
|
577
|
+
never terminate (`edit: 'view'`, `view: 'edit'`). A loop that goes through tuples (folders in
|
|
578
|
+
folders, groups in groups) is legitimate: it is cut at run time by cycle protection and by
|
|
579
|
+
`maxDepth` (default 16). When the depth limit cuts an evaluation the answer is `DEPTH_EXCEEDED`,
|
|
580
|
+
never a guess.
|
|
581
|
+
|
|
582
|
+
An actor is the subject `<type>:<id>` (`user:ada`, `service:billing-worker`); `subjectOf`
|
|
583
|
+
changes that. `SchemaCheckedTupleStore` wraps a store and refuses, on write, a tuple the schema
|
|
584
|
+
does not describe.
|
|
585
|
+
|
|
586
|
+
`expand` answers "why is this allowed" for debugging: the whole evaluation tree, every branch
|
|
587
|
+
visited, each node saying which object and relation were evaluated, how (`relation`, `direct`,
|
|
588
|
+
`userset`, `computed`, `union`, `intersection`, `exclusion`, `from`, `cycle`, `depth-limit`),
|
|
589
|
+
with which tuple and with what answer.
|
|
590
|
+
|
|
591
|
+
**The cost of listing, honestly.** `listAccessible` returns `{ kind: 'ids' }` by reverse
|
|
592
|
+
expansion: starting from the actor it reads the tuples of every subject it can reach (the
|
|
593
|
+
actor, its groups, the objects those lead to, and their usersets), which costs one store read
|
|
594
|
+
per reached subject, then runs one check per object of the requested type it met. That is fine
|
|
595
|
+
for an actor reaching up to a few thousand tuples. It is not a search index: beyond `listLimit`
|
|
596
|
+
reached subjects (default 10000) it throws `ReverseExpansionLimitError` rather than return a
|
|
597
|
+
partial list. An application with large shared hierarchies should delegate listing to a
|
|
598
|
+
dedicated service, or keep a projection of accessible ids.
|
|
599
|
+
|
|
600
|
+
**What of Zanzibar is not implemented.**
|
|
601
|
+
|
|
602
|
+
- No zookies or consistency tokens: every check reads the store as it is now. Consistency is
|
|
603
|
+
that of your store; the "new enemy" problem is avoided only when tuples are written in the
|
|
604
|
+
transaction of the change they describe (below) and read from the primary.
|
|
605
|
+
- No caveats or conditional tuples. A condition on attributes belongs to a policy.
|
|
606
|
+
- No distributed evaluation, no check or expand caching, no request deduplication or hedging,
|
|
607
|
+
no parallel fan-out: the evaluation is sequential, in process, bounded by `maxDepth`.
|
|
608
|
+
- No public wildcard subject (`user:*`), no namespace versioning or schema migration tooling,
|
|
609
|
+
no watch API or changelog.
|
|
610
|
+
- `expand` is the trace of one check for one actor, not the userset tree of Zanzibar's Expand.
|
|
611
|
+
|
|
612
|
+
**Delegating to a dedicated service.** OpenFGA and SpiceDB implement the full model. They are
|
|
613
|
+
adapters of the same `Authorizer` port, written in the platform, with their SDK as a dependency
|
|
614
|
+
of the project and not of this package:
|
|
615
|
+
|
|
616
|
+
```ts
|
|
617
|
+
// platform/auth/openfga-authorizer.ts (outline)
|
|
618
|
+
export class OpenFgaAuthorizer implements Authorizer {
|
|
619
|
+
constructor(
|
|
620
|
+
private readonly client: OpenFgaClient,
|
|
621
|
+
private readonly mapping: PermissionMapping,
|
|
622
|
+
) {}
|
|
623
|
+
|
|
624
|
+
async check({ actor, permission, resource }: CheckRequest): Promise<Decision> {
|
|
625
|
+
if (isAnonymous(actor)) {
|
|
626
|
+
return deny(DenialReason.anonymous);
|
|
627
|
+
}
|
|
628
|
+
if (resource === undefined) {
|
|
629
|
+
return deny(DenialReason.resourceRequired);
|
|
630
|
+
}
|
|
631
|
+
const relation = this.mapping[resource.type]?.[permission.id];
|
|
632
|
+
if (relation === undefined) {
|
|
633
|
+
return deny(DenialReason.unmappedPermission);
|
|
634
|
+
}
|
|
635
|
+
const { allowed } = await this.client.check({
|
|
636
|
+
user: `${actor.type}:${actor.id}`,
|
|
637
|
+
relation,
|
|
638
|
+
object: `${resource.type}:${resource.id}`,
|
|
639
|
+
});
|
|
640
|
+
return allowed ? ALLOWED : deny(DenialReason.noRelation);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async listAccessible({ actor, permission, resourceType }: ListRequest): Promise<AccessScope> {
|
|
644
|
+
const relation = this.mapping[resourceType]?.[permission.id];
|
|
645
|
+
if (isAnonymous(actor) || relation === undefined) {
|
|
646
|
+
return { kind: 'none' };
|
|
647
|
+
}
|
|
648
|
+
const { objects } = await this.client.listObjects({ user: `${actor.type}:${actor.id}`, relation, type: resourceType });
|
|
649
|
+
return { kind: 'ids', ids: objects.map((object) => object.slice(resourceType.length + 1)) };
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
With SpiceDB the same two methods call `CheckPermission` and `LookupResources`; the tuple
|
|
655
|
+
writes of the next section become `WriteRelationships`, through the outbox when the service is
|
|
656
|
+
remote, since a remote call cannot share the transaction. Hold the adapter to
|
|
657
|
+
`authorizerContract` in an integration test, and carry the consistency token the service
|
|
658
|
+
returns in the `context` of the request if you need read-your-writes.
|
|
659
|
+
|
|
660
|
+
### Keeping relationships in sync
|
|
661
|
+
|
|
662
|
+
The domain and the handlers never write tuples. The application declares a port in its own
|
|
663
|
+
words; the adapter knows the model.
|
|
664
|
+
|
|
665
|
+
```ts
|
|
666
|
+
// application/ports/document-grants.port.ts
|
|
667
|
+
export abstract class DocumentGrants {
|
|
668
|
+
abstract ownerAssigned(documentId: string, ownerId: string): Promise<void>;
|
|
669
|
+
|
|
670
|
+
abstract sharedWithGroup(documentId: string, groupId: string): Promise<void>;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// application/commands/create-document/create-document.handler.ts
|
|
674
|
+
async execute(command: CreateDocument): Promise<string> {
|
|
675
|
+
const document = Document.create(this.ids.next(), command.title);
|
|
676
|
+
await this.grants.ownerAssigned(document.id, command.actor.id);
|
|
677
|
+
await this.documents.save(document);
|
|
678
|
+
return document.id;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// infrastructure/external/tuple-document-grants.ts
|
|
682
|
+
export class TupleDocumentGrants implements DocumentGrants {
|
|
683
|
+
constructor(private readonly tuples: RelationTupleStore) {}
|
|
684
|
+
|
|
685
|
+
ownerAssigned(documentId: string, ownerId: string): Promise<void> {
|
|
686
|
+
return this.tuples.write([{ object: `document:${documentId}`, relation: 'owner', subject: `user:${ownerId}` }]);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
The command bus runs the handler in the unit of work, and a SQL-backed `RelationTupleStore`
|
|
692
|
+
writes in that transaction: the document and its owner tuple are committed together or not at
|
|
693
|
+
all. An event handler reacting to a domain event of the aggregate (`DocumentShared`) can call
|
|
694
|
+
the same port: domain events are dispatched at the end of the unit of work of the command,
|
|
695
|
+
before its commit, so with a SQL-backed store its tuple commits or rolls back with the aggregate too. Only a store
|
|
696
|
+
that is not in that transaction (a remote authorization service) makes the write a second
|
|
697
|
+
step, which must then be idempotent (writing a tuple twice changes nothing) and retried,
|
|
698
|
+
through the outbox. `test/relationship-sync.spec.ts` is the tested version of the write from
|
|
699
|
+
the handler, with the rollback case; hold your SQL store to `relationTupleStoreContract`.
|
|
700
|
+
|
|
701
|
+
## The current actor
|
|
702
|
+
|
|
703
|
+
**Use cases get it explicitly.** It is data of the command or query (`command.actor`), put
|
|
704
|
+
there by the route or the consumer. Handlers and the domain never read an ambient actor: a use
|
|
705
|
+
case then says who acts in its signature, a test passes whoever it wants, and a consumer or a
|
|
706
|
+
job is not at the mercy of a context nobody set.
|
|
707
|
+
|
|
708
|
+
**Infrastructure reads the execution scope.** Audit columns and tenant scoping apply to every
|
|
709
|
+
statement and should not be threaded through every method of every repository. The
|
|
710
|
+
`ExecutionScope` of the kernel is opened once per request, consumed event or job run at the
|
|
711
|
+
boundary (the `executionScope` option of `@ddd-ts-kit/http`, the integration event handlers
|
|
712
|
+
and the scheduled jobs of `@ddd-ts-kit/di`), and read by adapters only, through the three
|
|
713
|
+
helpers of the kernel:
|
|
714
|
+
|
|
715
|
+
```ts
|
|
716
|
+
// contexts/billing/infrastructure/persistence/drizzle-invoice.repository.ts
|
|
717
|
+
import { stampCreation, stampUpdate, tenantScopeOf } from '@ddd-ts-kit/kernel';
|
|
718
|
+
|
|
719
|
+
@Repository(InvoiceRepository, [Database, ExecutionScope, Clock])
|
|
720
|
+
export class DrizzleInvoiceRepository implements InvoiceRepository {
|
|
721
|
+
constructor(
|
|
722
|
+
private readonly context: Database,
|
|
723
|
+
private readonly scope: ExecutionScope,
|
|
724
|
+
private readonly clock: Clock,
|
|
725
|
+
) {}
|
|
726
|
+
|
|
727
|
+
async findById(id: string): Promise<Invoice | undefined> {
|
|
728
|
+
const [row] = await this.context
|
|
729
|
+
.executor()
|
|
730
|
+
.select()
|
|
731
|
+
.from(invoices)
|
|
732
|
+
.where(and(eq(invoices.id, id), eq(invoices.tenantId, tenantScopeOf(this.scope).tenantId)));
|
|
733
|
+
return row === undefined ? undefined : toInvoice(row);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// save: { ...toRow(invoice), ...tenantScopeOf(this.scope), ...stampCreation(this.scope, this.clock) }
|
|
737
|
+
// on insert, { ...toRow(invoice), ...stampUpdate(this.scope, this.clock) } on update
|
|
738
|
+
}
|
|
739
|
+
```
|
|
740
|
+
|
|
741
|
+
The whole repository, tested, is in the README of each adapter package (`@ddd-ts-kit/drizzle`,
|
|
742
|
+
`knex`, `prisma`, `typeorm`). `kernelModule()` binds `ExecutionScope` to an
|
|
743
|
+
`AsyncLocalStorageExecutionScope`, which follows the asynchronous call chain so concurrent
|
|
744
|
+
requests never mix, and `ActorContext` to a `ScopedActorContext` over it: the current actor is
|
|
745
|
+
the actor of the current execution context, so the two cannot disagree: `ActorContext` is a
|
|
746
|
+
view of the scope, and an adapter written against it keeps working. New adapters, and the
|
|
747
|
+
ones the CLI generates, name `ExecutionScope`. `require()` throws `NoExecutionContextError` (or
|
|
748
|
+
`NoCurrentActorError`) when a boundary opened nothing, and `tenantScopeOf` throws
|
|
749
|
+
`NoCurrentTenantError` for work that names no tenant: wiring mistakes, found by the first test.
|
|
750
|
+
This is defence in depth, not authorization: the tenant filter of a repository stops a bug from
|
|
751
|
+
leaking rows, the permission is still decided in the application layer.
|
|
752
|
+
|
|
753
|
+
## Filtering lists
|
|
754
|
+
|
|
755
|
+
A list is filtered by the query, never in memory after loading. The handler asks for a scope
|
|
756
|
+
and hands it to the read port:
|
|
757
|
+
|
|
758
|
+
```ts
|
|
759
|
+
// application/queries/search-invoices/search-invoices.handler.ts
|
|
760
|
+
async execute(query: SearchInvoices): Promise<readonly InvoiceSummary[]> {
|
|
761
|
+
const scope = await this.access.scope(query.actor, billingPermissions['invoice.read'], 'invoice');
|
|
762
|
+
return this.invoices.search(scope, query.page);
|
|
763
|
+
}
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
```ts
|
|
767
|
+
// infrastructure/persistence/drizzle-invoice-read-model.ts
|
|
768
|
+
function whereOf(scope: AccessScope): SQL | undefined {
|
|
769
|
+
switch (scope.kind) {
|
|
770
|
+
case 'all':
|
|
771
|
+
return undefined;
|
|
772
|
+
case 'none':
|
|
773
|
+
return sql`false`;
|
|
774
|
+
case 'ids':
|
|
775
|
+
return inArray(invoices.id, [...scope.ids]);
|
|
776
|
+
case 'filter':
|
|
777
|
+
return and(...Object.entries(scope.filter).map(([column, value]) => eq(columnOf(column), value)));
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
| `AccessScope` | Given by | The read port |
|
|
783
|
+
|---|---|---|
|
|
784
|
+
| `{ kind: 'all' }` | RBAC when the role grants the permission | adds nothing |
|
|
785
|
+
| `{ kind: 'none' }` | any model, and always for the anonymous actor | returns an empty page without querying |
|
|
786
|
+
| `{ kind: 'filter', filter }` | RBAC with the tenant check: `{ tenantId }` | adds one equality per entry, on columns it chooses to map |
|
|
787
|
+
| `{ kind: 'ids', ids }` | ReBAC | adds `id IN (...)`, or joins a temporary table when the list is long |
|
|
788
|
+
|
|
789
|
+
`Access.scope` throws `ListingNotSupportedError` when the authorizer has no `listAccessible`
|
|
790
|
+
(attribute policies): better a loud failure than a list silently filtered by a guess.
|
|
791
|
+
|
|
792
|
+
## Testing
|
|
793
|
+
|
|
794
|
+
```ts
|
|
795
|
+
import { anActor, AllowAll, RecordingAuthorizer, authorizerContract } from '@ddd-ts-kit/auth/testing';
|
|
796
|
+
|
|
797
|
+
const accountant = anActor().named('ada').ofTenant('acme').withRole('accountant').build();
|
|
798
|
+
```
|
|
799
|
+
|
|
800
|
+
- **Handlers.** `createTestApplication({ modules: [billingModule] })` allows every use case: a
|
|
801
|
+
handler spec is not about rights. `actor` sets who infrastructure sees through
|
|
802
|
+
`ActorContext` (default: the user `test-user`); `app.actorContext.actAs(actor)` changes it.
|
|
803
|
+
The actor of a use case is the one the test puts in the command.
|
|
804
|
+
- **Rights of a context.** List `authorizationModule({ authorizer })` with the real model, or
|
|
805
|
+
with a `RecordingAuthorizer` to assert what was asked as data (`authorizer.checks`); replace
|
|
806
|
+
the authorizer of a composed application with `fake(Authorizer, new AllowAll())`. Add
|
|
807
|
+
`authorization: 'required'` so that a use case without declaration fails the spec at wiring.
|
|
808
|
+
- **Policies.** Plain objects: call `policy.allows(actor, subject, { now })`.
|
|
809
|
+
- **Boundaries.** `FakeAuthenticator({ 'token-of-ada': ada })` behind `principalResolver`; a credential may stand for a whole session, `{ 'token-of-support': aSession().of(ada).impersonatedBy('support-7').build() }`.
|
|
810
|
+
- **Your own adapters.** Hold an `Authorizer` (an OpenFGA adapter, a custom model) to
|
|
811
|
+
`authorizerContract(runner, { name, create })` and a tuple store to
|
|
812
|
+
`relationTupleStoreContract(runner, { name, create })`, with `{ describe, it, expect,
|
|
813
|
+
beforeEach }` of your runner.
|
|
814
|
+
|
|
815
|
+
`anActor()` is a class on the `Builder` of `@ddd-ts-kit/testing`, so it nests in other builders
|
|
816
|
+
(`anInvoice().issuedBy(anActor().named('ada'))`) and is immutable like them. `AllowAll` and
|
|
817
|
+
`DenyAll` are stubs for specs that are not about rights; they are not held to the authorizer
|
|
818
|
+
contract, which they contradict by design.
|
|
819
|
+
|
|
820
|
+
## The shortcuts this design removes
|
|
821
|
+
|
|
822
|
+
| Shortcut | Why it is not needed any more | Caught by |
|
|
823
|
+
|---|---|---|
|
|
824
|
+
| a role test in a controller or a route (`if (!user.roles.includes('admin'))`) | the route only builds the command; the bus middleware decides | `authz/in-application` |
|
|
825
|
+
| `if (user.isAdmin)` or an ownership test in an aggregate or a domain service | rights are permissions and policies in `application/authorization/`; the domain holds invariants | `authz/in-application`, `layer/domain-purity` |
|
|
826
|
+
| a use case reachable because nobody thought about it | `.requires(...)` or `.public()`, or the application is not created (`authorization: 'required'`) and the message is refused (`UndeclaredUseCaseError`) | `authz/declared` |
|
|
827
|
+
| a handler reading "the current user" from a global or a request scope | the actor is the first argument of `AuthorizedCommand` and `AuthorizedQuery`; `requires` does not compile without it | `authz/actor-explicit` |
|
|
828
|
+
| a repository or an adapter deciding who may see a row by role | infrastructure only applies the scope it is handed and the tenant of the `ExecutionScope` | `authz/in-application` |
|
|
829
|
+
| `invoices.filter((invoice) => canRead(user, invoice))` after loading | `Access.scope` gives an `AccessScope` the read port turns into a `WHERE` clause | `authz/in-application` |
|
|
830
|
+
| the raw token passed down to a handler or stored on the user object | `Authenticator` answers an `Actor`, plain data without the credential | `authz/actor-explicit` |
|
|
831
|
+
| `if` chains over ownership and status inside a handler | a named policy with a spec of its own, asked through `Access.authorize` | `authz/in-application` |
|
|
832
|
+
| `new Date()` in a rule about time | policies and conditions receive `now` from the `Clock` port | `purity/no-system-clock` |
|
|
833
|
+
| a string of the model (`'admin'`, `'editor'`) scattered in the code | roles and relations appear only in the composition root and in adapters; the code names permissions | `authz/in-application` |
|
|
834
|
+
| a refusal answered with a message quoting the element | `Decision.reason` is a stable machine code, checked by the contract suite | the contract suite |
|
|
835
|
+
| a one-off bypass "for the demo" | a deviation is recorded as a decision or it is a defect | `governance/recorded-deviation` |
|