From 135f7fd51aed4a7af2bee25e6d72ea786c2f4fa2 Mon Sep 17 00:00:00 2001 From: Marcel Menk Date: Sat, 18 Jul 2026 20:00:22 +0200 Subject: [PATCH] feat: personal access token auth --- .../backend-agent-controller/README.md | 2 +- .../src/app/app.module.ts | 16 +- .../src/typeorm.config.ts | 2 + .../src/i18n/messages.de.xlf | 132 ++++ .../src/i18n/messages.xlf | 108 ++++ .../src/app/app.module.ts | 5 +- .../src/typeorm.config.ts | 3 +- .../src/i18n/messages.de.xlf | 132 ++++ .../src/i18n/messages.xlf | 108 ++++ docs/agenstra/features/authentication.md | 30 +- .../deployment/environment-configuration.md | 2 +- docs/decabill/features/authentication.md | 30 +- graph/graph.json | 585 +++++++++++++++++- .../spec/openapi.yaml | 267 ++++++++ .../feature-agent-controller/src/index.ts | 1 + .../src/lib/auth/agenstra-pat.scopes.spec.ts | 30 + .../src/lib/auth/agenstra-pat.scopes.ts | 27 + ...ent-agent-autonomy-directory.controller.ts | 3 +- .../client-agent-autonomy.controller.ts | 4 +- .../client-statistics.controller.ts | 8 +- ...ients-agent-automation-proxy.controller.ts | 8 +- ...ents-configuration-overrides.controller.ts | 4 + .../clients-deployments.controller.ts | 8 +- .../lib/controllers/clients-vcs.controller.ts | 8 +- .../controllers/clients.controller.spec.ts | 12 +- .../src/lib/controllers/clients.controller.ts | 50 +- .../context-import.controller.spec.ts | 13 + .../controllers/context-import.controller.ts | 9 +- .../filter-rules.controller.spec.ts | 12 + .../controllers/filter-rules.controller.ts | 9 +- .../controllers/knowledge-tree.controller.ts | 16 +- .../lib/controllers/statistics.controller.ts | 4 +- .../ticket-automation.controller.ts | 9 +- .../src/lib/controllers/tickets.controller.ts | 14 +- .../src/lib/modules/clients.module.spec.ts | 3 + .../src/lib/modules/clients.module.ts | 2 + .../assert-notification-admin.util.ts | 4 +- .../services/agent-console-status.service.ts | 1 + .../src/lib/services/clients.service.ts | 42 +- .../src/lib/services/tickets.service.ts | 4 +- .../lib/container/container.component.html | 12 + .../src/lib/container/container.component.ts | 16 +- .../feature-billing-manager/spec/openapi.yaml | 268 ++++++++ .../feature-billing-manager/src/index.ts | 6 +- .../src/lib/auth/billing-pat.scopes.spec.ts | 37 ++ .../src/lib/auth/billing-pat.scopes.ts | 32 + .../src/lib/billing.module.ts | 2 + .../controllers/admin-billing.controller.ts | 26 +- .../admin-customer-auto-billing.controller.ts | 3 +- .../admin-customer-profiles.controller.ts | 3 +- .../admin-datev-exports.controller.ts | 3 +- .../admin-promotions.controller.ts | 3 +- .../lib/controllers/admin-usage.controller.ts | 3 +- .../controllers/availability.controller.ts | 2 + .../lib/controllers/backorders.controller.ts | 4 + .../cloud-init-configs.controller.ts | 3 +- .../customer-auto-billing.controller.ts | 2 + .../customer-profiles.controller.ts | 2 + .../lib/controllers/invoices.controller.ts | 18 +- .../src/lib/controllers/pricing.controller.ts | 2 + .../lib/controllers/promotions.controller.ts | 2 + .../controllers/service-plans.controller.ts | 9 +- .../controllers/service-types.controller.ts | 10 +- .../subscription-items.controller.ts | 6 + .../controllers/subscriptions.controller.ts | 7 + .../src/lib/controllers/usage.controller.ts | 2 + .../billing-identity-email-bridge.module.ts | 9 +- .../lib/modules/billing-pat-auth.module.ts | 10 + .../lib/modules/billing-users-auth.module.ts | 10 + .../controllers/admin-projects.controller.ts | 11 +- .../project-milestones.controller.ts | 6 + .../controllers/project-tickets.controller.ts | 9 + .../project-time-entries.controller.ts | 5 + .../controllers/projects.controller.ts | 2 + .../projects/utils/project-access.utils.ts | 3 +- .../lib/utils/billing-access.utils.spec.ts | 32 + .../src/lib/utils/billing-access.utils.ts | 13 +- .../lib/container/container.component.html | 12 + .../src/lib/container/container.component.ts | 20 +- .../backend/feature-auth/src/index.ts | 13 + .../src/lib/constants/pat.constants.ts | 39 ++ .../src/lib/controllers/auth.controller.ts | 8 + .../pat-token-exchange.controller.ts | 27 + .../personal-access-tokens.controller.ts | 131 ++++ .../src/lib/controllers/users.controller.ts | 2 + .../decorators/require-scopes.decorator.ts | 22 + .../lib/dto/auth/personal-access-token.dto.ts | 63 ++ .../src/lib/guards/keycloak-auth.guard.ts | 10 +- .../src/lib/guards/keycloak-roles.guard.ts | 8 +- .../lib/guards/pat-bearer-auth.guard.spec.ts | 129 ++++ .../src/lib/guards/pat-bearer-auth.guard.ts | 143 +++++ .../src/lib/guards/pat-scopes.guard.spec.ts | 138 +++++ .../src/lib/guards/pat-scopes.guard.ts | 73 +++ .../src/lib/guards/users-auth.guard.spec.ts | 116 +++- .../src/lib/guards/users-auth.guard.ts | 62 +- .../src/lib/guards/users-roles.guard.spec.ts | 34 + .../src/lib/guards/users-roles.guard.ts | 17 +- .../lib/modules/keycloak-user-sync.module.ts | 23 +- .../src/lib/modules/pat-auth.module.ts | 67 ++ .../src/lib/modules/users-auth.module.ts | 74 +-- .../personal-access-tokens.repository.ts | 52 ++ .../src/lib/services/auth.service.spec.ts | 47 +- .../src/lib/services/auth.service.ts | 39 +- .../src/lib/services/client-users.service.ts | 14 +- .../personal-access-token.service.spec.ts | 402 ++++++++++++ .../services/personal-access-token.service.ts | 341 ++++++++++ .../lib/services/socket-auth.service.spec.ts | 29 + .../src/lib/services/socket-auth.service.ts | 22 + .../src/lib/services/users.service.spec.ts | 22 + .../src/lib/services/users.service.ts | 3 +- .../get-keycloak-pat-auth-guards.spec.ts | 51 ++ .../lib/utils/get-keycloak-pat-auth-guards.ts | 37 ++ .../src/lib/utils/users-jwt-session.util.ts | 6 + .../identity/backend/util-auth/src/index.ts | 3 + .../lib/bull-board-keycloak.guards.spec.ts | 28 +- .../src/lib/bull-board-keycloak.guards.ts | 22 +- .../src/lib/client-access.utils.spec.ts | 115 ++++ .../util-auth/src/lib/client-access.utils.ts | 59 +- .../user-personal-access-token.entity.spec.ts | 26 + .../user-personal-access-token.entity.ts | 40 ++ .../src/lib/identity-pat.scopes.spec.ts | 11 + .../util-auth/src/lib/identity-pat.scopes.ts | 7 + ...reateUserPersonalAccessTokensTable.spec.ts | 38 ++ ...000_CreateUserPersonalAccessTokensTable.ts | 109 ++++ .../frontend/data-access-auth/src/index.ts | 6 + .../src/lib/services/auth.service.spec.ts | 91 +++ .../src/lib/services/auth.service.ts | 34 + .../authentication.effects.spec.ts | 68 ++ .../authentication/authentication.effects.ts | 26 +- .../personal-access-tokens.actions.ts | 80 +++ .../personal-access-tokens.effects.spec.ts | 175 ++++++ .../personal-access-tokens.effects.ts | 123 ++++ .../personal-access-tokens.facade.spec.ts | 62 ++ .../personal-access-tokens.facade.ts | 78 +++ .../personal-access-tokens.reducer.spec.ts | 233 +++++++ .../personal-access-tokens.reducer.ts | 151 +++++ .../personal-access-tokens.selectors.spec.ts | 56 ++ .../personal-access-tokens.selectors.ts | 41 ++ .../lib/types/personal-access-token.types.ts | 27 + .../frontend/feature-auth/jest.config.ts | 2 + .../frontend/feature-auth/src/index.ts | 1 + .../src/lib/guards/auth.guard.spec.ts | 24 + .../feature-auth/src/lib/guards/auth.guard.ts | 31 +- .../src/lib/guards/login.guard.spec.ts | 21 + .../src/lib/guards/login.guard.ts | 29 +- .../src/lib/guards/pat-ui.guard.spec.ts | 70 +++ .../src/lib/guards/pat-ui.guard.ts | 21 + .../src/lib/identity-auth.routes.ts | 22 + .../src/lib/login/login.component.html | 7 +- .../src/lib/login/login.component.ts | 13 +- .../token-manager.component.html | 383 ++++++++++++ .../token-manager.component.scss | 66 ++ .../token-manager/token-manager.component.ts | 249 ++++++++ .../identity/frontend/util-auth/src/index.ts | 1 + .../src/lib/jwt-payload.utils.spec.ts | 75 +++ .../util-auth/src/lib/jwt-payload.utils.ts | 48 ++ .../webhook-endpoints.controller.ts | 5 + .../webhook-manager.component.html | 16 +- 158 files changed, 7213 insertions(+), 196 deletions(-) create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.spec.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.ts create mode 100644 libs/domains/decabill/backend/feature-billing-manager/src/lib/auth/billing-pat.scopes.spec.ts create mode 100644 libs/domains/decabill/backend/feature-billing-manager/src/lib/auth/billing-pat.scopes.ts create mode 100644 libs/domains/decabill/backend/feature-billing-manager/src/lib/modules/billing-pat-auth.module.ts create mode 100644 libs/domains/decabill/backend/feature-billing-manager/src/lib/modules/billing-users-auth.module.ts create mode 100644 libs/domains/identity/backend/feature-auth/src/lib/constants/pat.constants.ts create mode 100644 libs/domains/identity/backend/feature-auth/src/lib/controllers/pat-token-exchange.controller.ts create mode 100644 libs/domains/identity/backend/feature-auth/src/lib/controllers/personal-access-tokens.controller.ts create mode 100644 libs/domains/identity/backend/feature-auth/src/lib/decorators/require-scopes.decorator.ts create mode 100644 libs/domains/identity/backend/feature-auth/src/lib/dto/auth/personal-access-token.dto.ts create mode 100644 libs/domains/identity/backend/feature-auth/src/lib/guards/pat-bearer-auth.guard.spec.ts create mode 100644 libs/domains/identity/backend/feature-auth/src/lib/guards/pat-bearer-auth.guard.ts create mode 100644 libs/domains/identity/backend/feature-auth/src/lib/guards/pat-scopes.guard.spec.ts create mode 100644 libs/domains/identity/backend/feature-auth/src/lib/guards/pat-scopes.guard.ts create mode 100644 libs/domains/identity/backend/feature-auth/src/lib/modules/pat-auth.module.ts create mode 100644 libs/domains/identity/backend/feature-auth/src/lib/repositories/personal-access-tokens.repository.ts create mode 100644 libs/domains/identity/backend/feature-auth/src/lib/services/personal-access-token.service.spec.ts create mode 100644 libs/domains/identity/backend/feature-auth/src/lib/services/personal-access-token.service.ts create mode 100644 libs/domains/identity/backend/feature-auth/src/lib/utils/get-keycloak-pat-auth-guards.spec.ts create mode 100644 libs/domains/identity/backend/feature-auth/src/lib/utils/get-keycloak-pat-auth-guards.ts create mode 100644 libs/domains/identity/backend/util-auth/src/lib/entities/user-personal-access-token.entity.spec.ts create mode 100644 libs/domains/identity/backend/util-auth/src/lib/entities/user-personal-access-token.entity.ts create mode 100644 libs/domains/identity/backend/util-auth/src/lib/identity-pat.scopes.spec.ts create mode 100644 libs/domains/identity/backend/util-auth/src/lib/identity-pat.scopes.ts create mode 100644 libs/domains/identity/backend/util-auth/src/lib/migrations/1776100000000_CreateUserPersonalAccessTokensTable.spec.ts create mode 100644 libs/domains/identity/backend/util-auth/src/lib/migrations/1776100000000_CreateUserPersonalAccessTokensTable.ts create mode 100644 libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.actions.ts create mode 100644 libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.effects.spec.ts create mode 100644 libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.effects.ts create mode 100644 libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.facade.spec.ts create mode 100644 libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.facade.ts create mode 100644 libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.reducer.spec.ts create mode 100644 libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.reducer.ts create mode 100644 libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.selectors.spec.ts create mode 100644 libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.selectors.ts create mode 100644 libs/domains/identity/frontend/data-access-auth/src/lib/types/personal-access-token.types.ts create mode 100644 libs/domains/identity/frontend/feature-auth/src/lib/guards/pat-ui.guard.spec.ts create mode 100644 libs/domains/identity/frontend/feature-auth/src/lib/guards/pat-ui.guard.ts create mode 100644 libs/domains/identity/frontend/feature-auth/src/lib/token-manager/token-manager.component.html create mode 100644 libs/domains/identity/frontend/feature-auth/src/lib/token-manager/token-manager.component.scss create mode 100644 libs/domains/identity/frontend/feature-auth/src/lib/token-manager/token-manager.component.ts create mode 100644 libs/domains/identity/frontend/util-auth/src/lib/jwt-payload.utils.spec.ts create mode 100644 libs/domains/identity/frontend/util-auth/src/lib/jwt-payload.utils.ts diff --git a/apps/agenstra/backend-agent-controller/README.md b/apps/agenstra/backend-agent-controller/README.md index acecdd9d3..a8a2f2d89 100644 --- a/apps/agenstra/backend-agent-controller/README.md +++ b/apps/agenstra/backend-agent-controller/README.md @@ -221,7 +221,7 @@ See the [library documentation](../../libs/domains/agenstra/backend/feature-agen - `AUTHENTICATION_METHOD` - `api-key`, `keycloak`, or `users` (default: api-key when STATIC_API_KEY set, else keycloak) - `STATIC_API_KEY` - Static API key (required when AUTHENTICATION_METHOD=api-key) -- `JWT_SECRET` - JWT signing secret (required when AUTHENTICATION_METHOD=users) +- `JWT_SECRET` - JWT signing secret (required when `AUTHENTICATION_METHOD=users` or `keycloak`; used for users sessions and PAT exchange) - `KEYCLOAK_SERVER_URL` - Keycloak server URL (optional, used for server URL if different from auth server URL) - `KEYCLOAK_AUTH_SERVER_URL` - Keycloak authentication server URL (required for Keycloak auth) - `KEYCLOAK_REALM` - Keycloak realm name (required for Keycloak auth) diff --git a/apps/agenstra/backend-agent-controller/src/app/app.module.ts b/apps/agenstra/backend-agent-controller/src/app/app.module.ts index 3959b331d..c430bfcaa 100644 --- a/apps/agenstra/backend-agent-controller/src/app/app.module.ts +++ b/apps/agenstra/backend-agent-controller/src/app/app.module.ts @@ -3,16 +3,19 @@ import { IdentityEmailBridgeModule, IdentityNotificationBridgeModule, IdentityStatisticsBridgeModule, + AGENSTRA_PAT_SCOPES, } from '@forepath/agenstra/backend/feature-agent-controller'; import { MonitoringModule } from '@forepath/shared/backend'; import { BullBoardSkippingThrottlerGuard, getAuthenticationMethod, getHybridAuthGuards, + getKeycloakPatAuthGuards, getRateLimitConfig, KeycloakModule, KeycloakService, KeycloakUserSyncModule, + PatAuthModule, UsersAuthModule, } from '@forepath/identity/backend'; import { getTypeOrmOptionsForQueueRole } from '@forepath/shared/backend'; @@ -33,19 +36,22 @@ const authMethod = getAuthenticationMethod(); ControllerQueueModule, ThrottlerModule.forRoot(getRateLimitConfig()), ...(authMethod === 'keycloak' - ? [KeycloakModule, KeycloakConnectModule.registerAsync({ useExisting: KeycloakService }), KeycloakUserSyncModule] + ? [ + KeycloakModule, + KeycloakConnectModule.registerAsync({ useExisting: KeycloakService }), + KeycloakUserSyncModule, + PatAuthModule.register({ patScopeCatalog: AGENSTRA_PAT_SCOPES }), + ] : []), - ...(authMethod === 'users' ? [UsersAuthModule] : []), + ...(authMethod === 'users' ? [UsersAuthModule.register({ patScopeCatalog: AGENSTRA_PAT_SCOPES })] : []), ClientsModule, IdentityStatisticsBridgeModule, IdentityNotificationBridgeModule, IdentityEmailBridgeModule, MonitoringModule, ], - // Use hybrid guards (checks STATIC_API_KEY to determine authentication method) providers: [ - ...getHybridAuthGuards(), - // Apply rate limiting globally to all routes + ...(authMethod === 'keycloak' ? getKeycloakPatAuthGuards() : getHybridAuthGuards()), { provide: APP_GUARD, useClass: BullBoardSkippingThrottlerGuard, diff --git a/apps/agenstra/backend-agent-controller/src/typeorm.config.ts b/apps/agenstra/backend-agent-controller/src/typeorm.config.ts index 7c1088ce5..0822e8756 100644 --- a/apps/agenstra/backend-agent-controller/src/typeorm.config.ts +++ b/apps/agenstra/backend-agent-controller/src/typeorm.config.ts @@ -38,6 +38,7 @@ import { ClientUserEntity, RevokedUserTokenEntity, UserEntity, + UserPersonalAccessTokenEntity, } from '@forepath/identity/backend'; import { DataSource, DataSourceOptions } from 'typeorm'; @@ -76,6 +77,7 @@ export const typeormConfig: DataSourceOptions = { ProvisioningReferenceEntity, UserEntity, RevokedUserTokenEntity, + UserPersonalAccessTokenEntity, StatisticsUserEntity, StatisticsClientEntity, StatisticsAgentEntity, diff --git a/apps/agenstra/frontend-agent-console/src/i18n/messages.de.xlf b/apps/agenstra/frontend-agent-console/src/i18n/messages.de.xlf index b9961c56d..c78cf6cca 100644 --- a/apps/agenstra/frontend-agent-console/src/i18n/messages.de.xlf +++ b/apps/agenstra/frontend-agent-console/src/i18n/messages.de.xlf @@ -1674,6 +1674,14 @@ Users Benutzer + + Access Tokens + Zugriffstoken + + + Tokens + Token + Filters Filterregeln @@ -4064,6 +4072,10 @@ User Management Benutzerverwaltung + + Personal Access Tokens + Persönliche Zugriffstoken + Login Anmelden @@ -4128,6 +4140,10 @@ Login Anmelden + + Personal access tokens cannot be used to sign in to the console. Use your account password instead. + Persönliche Zugriffstoken können nicht zur Anmeldung an der Konsole verwendet werden. Verwenden Sie stattdessen Ihr Kontopasswort. + Character of 6 Character of 6 @@ -4586,6 +4602,122 @@ Delete Löschen + + Personal Access Tokens + Persönliche Zugriffstoken + + + Create token + Token erstellen + + + Search tokens + Token suchen + + + Loading tokens... + Token werden geladen... + + + No personal access tokens + Keine persönlichen Zugriffstoken + + + scopes + Berechtigungen + + + Created + Erstellt + + + Expires + Läuft ab + + + No expiry + Kein Ablaufdatum + + + Last used + Zuletzt verwendet + + + Revoke token + Token widerrufen + + + Create personal access token + Persönliches Zugriffstoken erstellen + + + Close + Schließen + + + Token value + Tokenwert + + + Copy this token now. It will not be shown again. + Kopieren Sie dieses Token jetzt. Es wird nicht erneut angezeigt. + + + Copied + Kopiert + + + Copy + Kopieren + + + Done + Fertig + + + Name * + Name * + + + Expires at + Läuft ab am + + + Optional. Leave empty for a token that does not expire. + Optional. Leer lassen für ein Token ohne Ablaufdatum. + + + Scopes * + Berechtigungen * + + + Loading scopes... + Berechtigungen werden geladen... + + + No scopes available + Keine Berechtigungen verfügbar + + + Create + Erstellen + + + Revoke token + Token widerrufen + + + Are you sure you want to revoke the token ? + Möchten Sie das Token wirklich widerrufen? + + + This action cannot be undone. + Diese Aktion kann nicht rückgängig gemacht werden. + + + Revoke + Widerrufen + Documentation Dokumentation diff --git a/apps/agenstra/frontend-agent-console/src/i18n/messages.xlf b/apps/agenstra/frontend-agent-console/src/i18n/messages.xlf index 6827048db..7873cc5a2 100644 --- a/apps/agenstra/frontend-agent-console/src/i18n/messages.xlf +++ b/apps/agenstra/frontend-agent-console/src/i18n/messages.xlf @@ -1239,6 +1239,12 @@ Users + + Access Tokens + + + Tokens + Filters @@ -2963,6 +2969,9 @@ User Management + + Personal Access Tokens + Login @@ -3011,6 +3020,9 @@ Login + + Personal access tokens cannot be used to sign in to the console. Use your account password instead. + Close @@ -3381,6 +3393,102 @@ Delete + + Personal Access Tokens + + + Create token + + + Search tokens + + + Loading tokens... + + + No personal access tokens + + + scopes + + + Created + + + Expires + + + No expiry + + + Last used + + + Revoke token + + + Edit token + + + Edit personal access token + + + Create personal access token + + + Close + + + Token value + + + Copy this token now. It will not be shown again. + + + Copied + + + Copy + + + Done + + + Name * + + + Expires at + + + Optional. Leave empty for a token that does not expire. + + + Scopes * + + + Loading scopes... + + + No scopes available + + + Create + + + Update + + + Revoke token + + + Are you sure you want to revoke the token ? + + + This action cannot be undone. + + + Revoke + Documentation diff --git a/apps/decabill/backend-billing-manager/src/app/app.module.ts b/apps/decabill/backend-billing-manager/src/app/app.module.ts index 740d05726..e7162609d 100644 --- a/apps/decabill/backend-billing-manager/src/app/app.module.ts +++ b/apps/decabill/backend-billing-manager/src/app/app.module.ts @@ -2,12 +2,14 @@ import { BillingIdentityNotificationBridgeModule, BillingKeycloakUserSyncModule, BillingModule, + BillingPatAuthModule, BillingUsersAuthModule, } from '@forepath/decabill/backend'; import { BullBoardSkippingThrottlerGuard, getAuthenticationMethod, getHybridAuthGuards, + getKeycloakPatAuthGuards, getRateLimitConfig, KeycloakModule, KeycloakService, @@ -36,13 +38,14 @@ const authMethod = getAuthenticationMethod(); KeycloakModule, KeycloakConnectModule.registerAsync({ useExisting: KeycloakService }), BillingKeycloakUserSyncModule, + BillingPatAuthModule, ] : []), ...(authMethod === 'users' ? [BillingUsersAuthModule] : []), MonitoringModule, ], providers: [ - ...getHybridAuthGuards(), + ...(authMethod === 'keycloak' ? getKeycloakPatAuthGuards() : getHybridAuthGuards()), { provide: APP_GUARD, useClass: BullBoardSkippingThrottlerGuard, diff --git a/apps/decabill/backend-billing-manager/src/typeorm.config.ts b/apps/decabill/backend-billing-manager/src/typeorm.config.ts index ff0bf9659..97b7b93bb 100644 --- a/apps/decabill/backend-billing-manager/src/typeorm.config.ts +++ b/apps/decabill/backend-billing-manager/src/typeorm.config.ts @@ -35,7 +35,7 @@ import { } from '@forepath/decabill/backend'; import { WebhookDeliveryEntity, WebhookEndpointEntity, EmailDeliveryEntity } from '@forepath/shared/backend'; import { CorrelationAwareTypeOrmLogger } from '@forepath/shared/backend/util-http-context'; -import { RevokedUserTokenEntity, UserEntity } from '@forepath/identity/backend'; +import { RevokedUserTokenEntity, UserEntity, UserPersonalAccessTokenEntity } from '@forepath/identity/backend'; import { DataSource, DataSourceOptions } from 'typeorm'; function parseTypeOrmLogLevelsFromEnv( @@ -102,6 +102,7 @@ export const typeormConfig: DataSourceOptions = { PublicWithdrawalRequestEntity, UserEntity, RevokedUserTokenEntity, + UserPersonalAccessTokenEntity, WebhookEndpointEntity, WebhookDeliveryEntity, EmailDeliveryEntity, diff --git a/apps/decabill/frontend-billing-console/src/i18n/messages.de.xlf b/apps/decabill/frontend-billing-console/src/i18n/messages.de.xlf index 30fc36ac8..91b1243b8 100644 --- a/apps/decabill/frontend-billing-console/src/i18n/messages.de.xlf +++ b/apps/decabill/frontend-billing-console/src/i18n/messages.de.xlf @@ -910,6 +910,14 @@ Users Benutzer + + Access Tokens + Zugriffstoken + + + Tokens + Token + Billing Profiles Abrechnungsprofile @@ -2294,6 +2302,10 @@ User Management Benutzerverwaltung + + Personal Access Tokens + Persönliche Zugriffstoken + Login Anmelden @@ -2358,6 +2370,10 @@ Login Anmelden + + Personal access tokens cannot be used to sign in to the console. Use your account password instead. + Persönliche Zugriffstoken können nicht zur Anmeldung an der Konsole verwendet werden. Verwenden Sie stattdessen Ihr Kontopasswort. + Character of 6 Character of 6 @@ -2816,6 +2832,122 @@ Delete Löschen + + Personal Access Tokens + Persönliche Zugriffstoken + + + Create token + Token erstellen + + + Search tokens + Token suchen + + + Loading tokens... + Token werden geladen... + + + No personal access tokens + Keine persönlichen Zugriffstoken + + + scopes + Berechtigungen + + + Created + Erstellt + + + Expires + Läuft ab + + + No expiry + Kein Ablaufdatum + + + Last used + Zuletzt verwendet + + + Revoke token + Token widerrufen + + + Create personal access token + Persönliches Zugriffstoken erstellen + + + Close + Schließen + + + Token value + Tokenwert + + + Copy this token now. It will not be shown again. + Kopieren Sie dieses Token jetzt. Es wird nicht erneut angezeigt. + + + Copied + Kopiert + + + Copy + Kopieren + + + Done + Fertig + + + Name * + Name * + + + Expires at + Läuft ab am + + + Optional. Leave empty for a token that does not expire. + Optional. Leer lassen für ein Token ohne Ablaufdatum. + + + Scopes * + Berechtigungen * + + + Loading scopes... + Berechtigungen werden geladen... + + + No scopes available + Keine Berechtigungen verfügbar + + + Create + Erstellen + + + Revoke token + Token widerrufen + + + Are you sure you want to revoke the token ? + Möchten Sie das Token wirklich widerrufen? + + + This action cannot be undone. + Diese Aktion kann nicht rückgängig gemacht werden. + + + Revoke + Widerrufen + Documentation Dokumentation diff --git a/apps/decabill/frontend-billing-console/src/i18n/messages.xlf b/apps/decabill/frontend-billing-console/src/i18n/messages.xlf index d45c0b77f..a9821e4ce 100644 --- a/apps/decabill/frontend-billing-console/src/i18n/messages.xlf +++ b/apps/decabill/frontend-billing-console/src/i18n/messages.xlf @@ -986,6 +986,12 @@ Users + + Access Tokens + + + Tokens + Billing Profiles @@ -2589,6 +2595,9 @@ User Management + + Personal Access Tokens + Login @@ -2637,6 +2646,9 @@ Login + + Personal access tokens cannot be used to sign in to the console. Use your account password instead. + Close @@ -3007,6 +3019,102 @@ Delete + + Personal Access Tokens + + + Create token + + + Search tokens + + + Loading tokens... + + + No personal access tokens + + + scopes + + + Created + + + Expires + + + No expiry + + + Last used + + + Revoke token + + + Edit token + + + Edit personal access token + + + Create personal access token + + + Close + + + Token value + + + Copy this token now. It will not be shown again. + + + Copied + + + Copy + + + Done + + + Name * + + + Expires at + + + Optional. Leave empty for a token that does not expire. + + + Scopes * + + + Loading scopes... + + + No scopes available + + + Create + + + Update + + + Revoke token + + + Are you sure you want to revoke the token ? + + + This action cannot be undone. + + + Revoke + Documentation diff --git a/docs/agenstra/features/authentication.md b/docs/agenstra/features/authentication.md index f31ac3aa9..baabaa9ec 100644 --- a/docs/agenstra/features/authentication.md +++ b/docs/agenstra/features/authentication.md @@ -75,6 +75,23 @@ DISABLE_SIGNUP=false # Set to true to disable self-registration - First registered user gets admin role - Admin user management (CRUD operations) - Optional signup disable for controlled onboarding +- **Personal access tokens (PATs)** for machine/API automation (see below) + +### Personal access tokens (users and keycloak modes) + +User-bound tokens for scripts and CI. They are **not** a console login password. **Not available** when `AUTHENTICATION_METHOD=api-key`. + +| Concern | Behavior | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| Create / update / revoke | Console **Personal Access Tokens** page (`/settings/tokens`); interactive console session only (password JWT or Keycloak OIDC) | +| Exchange | `POST /auth/token` with `{ "token": "fp_pat_…" }` only (no email) → JWT with `amr: ["pat"]` and `scopes` | +| Console | Login rejects `fp_pat_` secrets; SPA rejects JWTs whose `amr` includes `pat`; WebSockets reject PAT JWTs | +| Keycloak | Local `users` row is synced on first authenticated request (`keycloakSub`); PAT CRUD and exchange require `JWT_SECRET` | +| Scopes | `AGENSTRA_PAT_SCOPES` — clients/tickets/knowledge/agents/imports/statistics + `users:admin` / `webhooks:admin`; each enforced via `@RequireScopes` | + +Compared to `STATIC_API_KEY`, PATs are per-user, scoped, rotatable, and do not grant cross-tenant admin by default. + +Updating a token changes **name** and **scopes** only; the secret is never rotated by update. Exchanged PAT JWTs embed `patId`; each request re-checks the PAT row so **revoke**, **expiry**, and **scope updates** take effect immediately. **Role demotion** also takes effect immediately: request roles come from the live DB user (not the JWT claim), admin-only scopes are stripped from the effective grant set, and changing a user’s role bumps `tokenVersion` so outstanding JWTs are rejected. ## Users Authentication Flow @@ -232,7 +249,8 @@ Admins can manage users via the user management interface or API: ### Authentication Endpoints (Public) -- `POST /api/auth/login` - Login with email/password +- `POST /api/auth/login` - Login with email/password (console; PATs rejected) +- `POST /api/auth/token` - Exchange personal access token for machine JWT (`amr: pat`) - `POST /api/auth/register` - Register new user (returns 503 when `DISABLE_SIGNUP=true`) - `POST /api/auth/confirm-email` - Confirm email with 6-character code - `POST /api/auth/request-password-reset` - Request password reset @@ -240,6 +258,16 @@ Admins can manage users via the user management interface or API: - `POST /api/auth/change-password` - Change password (authenticated; returns new JWT) - `POST /api/auth/logout` - Log out and invalidate all JWT sessions (users auth only) +### Personal Access Token Endpoints (Password session) + +- `GET /api/auth/token-scopes` - Grantable scopes for the current user +- `GET /api/auth/tokens` - List own tokens +- `POST /api/auth/tokens` - Create token (plaintext returned once) +- `PATCH /api/auth/tokens/:id` - Update own token name and scopes (secret unchanged) +- `DELETE /api/auth/tokens/:id` - Revoke own token +- `GET /api/users/:userId/tokens` - List tokens for a user (admin) +- `DELETE /api/users/:userId/tokens/:tokenId` - Revoke a user's token (admin) + ### User Management Endpoints (Admin Only) - `GET /api/users` - List users diff --git a/docs/decabill/deployment/environment-configuration.md b/docs/decabill/deployment/environment-configuration.md index a607b3f97..f09d5325c 100644 --- a/docs/decabill/deployment/environment-configuration.md +++ b/docs/decabill/deployment/environment-configuration.md @@ -34,7 +34,7 @@ Complete reference for environment variables used in Decabill. | `STATIC_API_KEY` | Static API key when using api-key mode | | `STATIC_API_KEY_TENANT_ID` | Optional tenant bind for API key auth (see DR-002) | | `DISABLE_SIGNUP` | When `true`, disables self-registration for users mode | -| `JWT_SECRET` | Required for users mode | +| `JWT_SECRET` | Required for users and keycloak modes (sessions / PAT exchange) | | `KEYCLOAK_*` | Keycloak URL, realm, client id/secret, token validation | ### Multi-tenancy diff --git a/docs/decabill/features/authentication.md b/docs/decabill/features/authentication.md index 09f827cfb..6beae816b 100644 --- a/docs/decabill/features/authentication.md +++ b/docs/decabill/features/authentication.md @@ -77,6 +77,23 @@ DISABLE_SIGNUP=false - First registered user gets admin role - Admin user management (CRUD, lock, unlock) - Optional signup disable for controlled onboarding +- **Personal access tokens (PATs)** for machine/API automation (see below) + +### Personal access tokens (users and keycloak modes) + +User-bound tokens for scripts and CI (for example usage recording). They are **not** a billing-console login password. **Not available** when `AUTHENTICATION_METHOD=api-key`. + +| Concern | Behavior | +| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| Create / update / revoke | Console **Personal Access Tokens** page (`/settings/tokens`); interactive console session only (password JWT or Keycloak OIDC) | +| Exchange | `POST /auth/token` with `{ "token": "fp_pat_…" }` only (no email) → JWT with `amr: ["pat"]` and `scopes` | +| Console | Login rejects `fp_pat_` secrets; SPA rejects JWTs whose `amr` includes `pat`; dashboard WebSockets reject PAT JWTs | +| Keycloak | Local `users` row is synced on first authenticated request (`keycloakSub`); PAT CRUD and exchange require `JWT_SECRET` | +| Scopes | `BILLING_PAT_SCOPES` — catalog/subscriptions/invoices/usage/projects/tickets/… + `users:admin` / `webhooks:admin`; each enforced via `@RequireScopes` | + +Prefer PATs over a shared `STATIC_API_KEY` for multi-tenant automation (see accepted risk DR-002). + +Updating a token changes **name** and **scopes** only; the secret is never rotated by update. Exchanged PAT JWTs embed `patId`; each request re-checks the PAT row so **revoke**, **expiry**, and **scope updates** take effect immediately. **Role demotion** also takes effect immediately: request roles come from the live DB user (not the JWT claim), admin-only scopes are stripped from the effective grant set, and changing a user’s role bumps `tokenVersion` so outstanding JWTs are rejected. ## Users Authentication Flow @@ -165,7 +182,8 @@ Frontend runtime config should set `authentication.disableSignup` to match the b ### Authentication Endpoints (Public) -- `POST /auth/login` - Login with email and password +- `POST /auth/login` - Login with email and password (console; PATs rejected) +- `POST /auth/token` - Exchange personal access token for machine JWT (`amr: pat`) - `POST /auth/register` - Register new user (503 when signup disabled) - `POST /auth/confirm-email` - Confirm email with code - `POST /auth/request-password-reset` - Request password reset @@ -173,6 +191,16 @@ Frontend runtime config should set `authentication.disableSignup` to match the b - `POST /auth/change-password` - Change password (authenticated; returns new JWT) - `POST /auth/logout` - Log out and invalidate all JWT sessions (users auth only) +### Personal Access Token Endpoints (Password session) + +- `GET /auth/token-scopes` - Grantable scopes for the current user +- `GET /auth/tokens` - List own tokens +- `POST /auth/tokens` - Create token (plaintext returned once) +- `PATCH /auth/tokens/{id}` - Update own token name and scopes (secret unchanged) +- `DELETE /auth/tokens/{id}` - Revoke own token +- `GET /users/{userId}/tokens` - List tokens for a user (admin) +- `DELETE /users/{userId}/tokens/{tokenId}` - Revoke a user's token (admin) + ### User Management Endpoints (Admin Only) - `GET /users` - List users diff --git a/graph/graph.json b/graph/graph.json index 7a49eceec..9062c25ad 100644 --- a/graph/graph.json +++ b/graph/graph.json @@ -1,6 +1,6 @@ { "version": 1, - "generatedAt": "2026-07-18T15:32:33.646Z", + "generatedAt": "2026-07-19T12:33:17.315Z", "nodes": [ { "id": "project:@forepath/test/mounted-plugin-fixture", @@ -6232,6 +6232,24 @@ "projectName": "decabill-backend-feature-billing-manager" } }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/modules/billing-pat-auth.module.ts", + "type": "module", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/modules/billing-pat-auth.module.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/modules/billing-users-auth.module.ts", + "type": "module", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/modules/billing-users-auth.module.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, { "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/payment-processors/processors/stripe-payment.processor.ts", "type": "provider", @@ -8795,6 +8813,23 @@ ] } }, + { + "id": "file:libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens", + "type": "state", + "attrs": { + "path": "libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens", + "languageOrKind": "ts", + "projectName": "identity-frontend-data-access-auth", + "sliceName": "personal-access-tokens", + "memberFiles": [ + "personal-access-tokens.actions.ts", + "personal-access-tokens.effects.ts", + "personal-access-tokens.facade.ts", + "personal-access-tokens.reducer.ts", + "personal-access-tokens.selectors.ts" + ] + } + }, { "id": "file:libs/domains/shared/frontend/util-configuration/README.md", "type": "readme", @@ -8966,6 +9001,15 @@ "projectName": "identity-frontend-feature-auth" } }, + { + "id": "file:libs/domains/identity/frontend/feature-auth/src/lib/guards/pat-ui.guard.ts", + "type": "guard", + "attrs": { + "path": "libs/domains/identity/frontend/feature-auth/src/lib/guards/pat-ui.guard.ts", + "languageOrKind": "ts", + "projectName": "identity-frontend-feature-auth" + } + }, { "id": "file:libs/domains/identity/frontend/feature-auth/src/lib/guards/signup-disabled.guard.ts", "type": "guard", @@ -8993,6 +9037,24 @@ "projectName": "identity-backend-feature-auth" } }, + { + "id": "file:libs/domains/identity/backend/feature-auth/src/lib/controllers/pat-token-exchange.controller.ts", + "type": "controller", + "attrs": { + "path": "libs/domains/identity/backend/feature-auth/src/lib/controllers/pat-token-exchange.controller.ts", + "languageOrKind": "ts", + "projectName": "identity-backend-feature-auth" + } + }, + { + "id": "file:libs/domains/identity/backend/feature-auth/src/lib/controllers/personal-access-tokens.controller.ts", + "type": "controller", + "attrs": { + "path": "libs/domains/identity/backend/feature-auth/src/lib/controllers/personal-access-tokens.controller.ts", + "languageOrKind": "ts", + "projectName": "identity-backend-feature-auth" + } + }, { "id": "file:libs/domains/identity/backend/feature-auth/src/lib/controllers/users.controller.ts", "type": "controller", @@ -9056,6 +9118,15 @@ "projectName": "identity-backend-feature-auth" } }, + { + "id": "file:libs/domains/identity/backend/feature-auth/src/lib/dto/auth/personal-access-token.dto.ts", + "type": "dto", + "attrs": { + "path": "libs/domains/identity/backend/feature-auth/src/lib/dto/auth/personal-access-token.dto.ts", + "languageOrKind": "ts", + "projectName": "identity-backend-feature-auth" + } + }, { "id": "file:libs/domains/identity/backend/feature-auth/src/lib/dto/auth/register.dto.ts", "type": "dto", @@ -9137,6 +9208,24 @@ "projectName": "identity-backend-feature-auth" } }, + { + "id": "file:libs/domains/identity/backend/feature-auth/src/lib/guards/pat-bearer-auth.guard.ts", + "type": "guard", + "attrs": { + "path": "libs/domains/identity/backend/feature-auth/src/lib/guards/pat-bearer-auth.guard.ts", + "languageOrKind": "ts", + "projectName": "identity-backend-feature-auth" + } + }, + { + "id": "file:libs/domains/identity/backend/feature-auth/src/lib/guards/pat-scopes.guard.ts", + "type": "guard", + "attrs": { + "path": "libs/domains/identity/backend/feature-auth/src/lib/guards/pat-scopes.guard.ts", + "languageOrKind": "ts", + "projectName": "identity-backend-feature-auth" + } + }, { "id": "file:libs/domains/identity/backend/feature-auth/src/lib/guards/users-auth.guard.ts", "type": "guard", @@ -9164,6 +9253,15 @@ "projectName": "identity-backend-feature-auth" } }, + { + "id": "file:libs/domains/identity/backend/feature-auth/src/lib/modules/pat-auth.module.ts", + "type": "module", + "attrs": { + "path": "libs/domains/identity/backend/feature-auth/src/lib/modules/pat-auth.module.ts", + "languageOrKind": "ts", + "projectName": "identity-backend-feature-auth" + } + }, { "id": "file:libs/domains/identity/backend/feature-auth/src/lib/modules/users-auth.module.ts", "type": "module", @@ -9182,6 +9280,15 @@ "projectName": "identity-backend-feature-auth" } }, + { + "id": "file:libs/domains/identity/backend/feature-auth/src/lib/repositories/personal-access-tokens.repository.ts", + "type": "repository", + "attrs": { + "path": "libs/domains/identity/backend/feature-auth/src/lib/repositories/personal-access-tokens.repository.ts", + "languageOrKind": "ts", + "projectName": "identity-backend-feature-auth" + } + }, { "id": "file:libs/domains/identity/backend/feature-auth/src/lib/repositories/revoked-user-tokens.repository.ts", "type": "repository", @@ -9227,6 +9334,15 @@ "projectName": "identity-backend-feature-auth" } }, + { + "id": "file:libs/domains/identity/backend/feature-auth/src/lib/services/personal-access-token.service.ts", + "type": "service", + "attrs": { + "path": "libs/domains/identity/backend/feature-auth/src/lib/services/personal-access-token.service.ts", + "languageOrKind": "ts", + "projectName": "identity-backend-feature-auth" + } + }, { "id": "file:libs/domains/identity/backend/feature-auth/src/lib/services/socket-auth.service.ts", "type": "service", @@ -9362,6 +9478,15 @@ "projectName": "identity-backend-util-auth" } }, + { + "id": "file:libs/domains/identity/backend/util-auth/src/lib/entities/user-personal-access-token.entity.ts", + "type": "entity", + "attrs": { + "path": "libs/domains/identity/backend/util-auth/src/lib/entities/user-personal-access-token.entity.ts", + "languageOrKind": "ts", + "projectName": "identity-backend-util-auth" + } + }, { "id": "file:libs/domains/identity/backend/util-auth/src/lib/entities/user.entity.ts", "type": "entity", @@ -11183,6 +11308,17 @@ "specKind": "openapi" } }, + { + "id": "api:HTTP:POST:/auth/token", + "type": "endpoint", + "attrs": { + "method": "POST", + "pathOrChannel": "/auth/token", + "operationId": "exchangePersonalAccessToken", + "summary": "Exchange personal access token for machine JWT", + "specKind": "openapi" + } + }, { "id": "api:HTTP:POST:/auth/register", "type": "endpoint", @@ -11249,6 +11385,83 @@ "specKind": "openapi" } }, + { + "id": "api:HTTP:GET:/auth/token-scopes", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/auth/token-scopes", + "operationId": "listPersonalAccessTokenScopes", + "summary": "List grantable PAT scopes for the current user", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:GET:/auth/tokens", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/auth/tokens", + "operationId": "listPersonalAccessTokens", + "summary": "List own personal access tokens", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:POST:/auth/tokens", + "type": "endpoint", + "attrs": { + "method": "POST", + "pathOrChannel": "/auth/tokens", + "operationId": "createPersonalAccessToken", + "summary": "Create a personal access token", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:PATCH:/auth/tokens/{id}", + "type": "endpoint", + "attrs": { + "method": "PATCH", + "pathOrChannel": "/auth/tokens/{id}", + "operationId": "updatePersonalAccessToken", + "summary": "Update own personal access token name and scopes", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:DELETE:/auth/tokens/{id}", + "type": "endpoint", + "attrs": { + "method": "DELETE", + "pathOrChannel": "/auth/tokens/{id}", + "operationId": "revokePersonalAccessToken", + "summary": "Revoke own personal access token", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:GET:/users/{userId}/tokens", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/users/{userId}/tokens", + "operationId": "listUserPersonalAccessTokens", + "summary": "List personal access tokens for a user (admin)", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:DELETE:/users/{userId}/tokens/{tokenId}", + "type": "endpoint", + "attrs": { + "method": "DELETE", + "pathOrChannel": "/users/{userId}/tokens/{tokenId}", + "operationId": "revokeUserPersonalAccessToken", + "summary": "Revoke a user's personal access token (admin)", + "specKind": "openapi" + } + }, { "id": "api:HTTP:GET:/users", "type": "endpoint", @@ -24177,6 +24390,11 @@ "to": "project:shared-frontend-util-configuration", "type": "depends_on" }, + { + "from": "project:identity-backend-feature-auth", + "to": "project:identity-backend-util-auth", + "type": "depends_on" + }, { "from": "project:shared-frontend-feature-docs", "to": "project:shared-frontend-util-docs-parser", @@ -28367,6 +28585,16 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/modules/billing-identity-notification-bridge.module.ts", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/modules/billing-pat-auth.module.ts", + "type": "contains" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/modules/billing-users-auth.module.ts", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/payment-processors/processors/stripe-payment.processor.ts", @@ -29777,6 +30005,11 @@ "to": "file:libs/domains/identity/frontend/data-access-auth/src/lib/state/authentication", "type": "contains" }, + { + "from": "project:identity-frontend-data-access-auth", + "to": "file:libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens", + "type": "contains" + }, { "from": "project:shared-frontend-util-configuration", "to": "file:libs/domains/shared/frontend/util-configuration/README.md", @@ -29872,6 +30105,11 @@ "to": "file:libs/domains/identity/frontend/feature-auth/src/lib/guards/login.guard.ts", "type": "contains" }, + { + "from": "project:identity-frontend-feature-auth", + "to": "file:libs/domains/identity/frontend/feature-auth/src/lib/guards/pat-ui.guard.ts", + "type": "contains" + }, { "from": "project:identity-frontend-feature-auth", "to": "file:libs/domains/identity/frontend/feature-auth/src/lib/guards/signup-disabled.guard.ts", @@ -29887,6 +30125,16 @@ "to": "file:libs/domains/identity/backend/feature-auth/src/lib/controllers/auth.controller.ts", "type": "contains" }, + { + "from": "project:identity-backend-feature-auth", + "to": "file:libs/domains/identity/backend/feature-auth/src/lib/controllers/pat-token-exchange.controller.ts", + "type": "contains" + }, + { + "from": "project:identity-backend-feature-auth", + "to": "file:libs/domains/identity/backend/feature-auth/src/lib/controllers/personal-access-tokens.controller.ts", + "type": "contains" + }, { "from": "project:identity-backend-feature-auth", "to": "file:libs/domains/identity/backend/feature-auth/src/lib/controllers/users.controller.ts", @@ -29922,6 +30170,11 @@ "to": "file:libs/domains/identity/backend/feature-auth/src/lib/dto/auth/logout.dto.ts", "type": "contains" }, + { + "from": "project:identity-backend-feature-auth", + "to": "file:libs/domains/identity/backend/feature-auth/src/lib/dto/auth/personal-access-token.dto.ts", + "type": "contains" + }, { "from": "project:identity-backend-feature-auth", "to": "file:libs/domains/identity/backend/feature-auth/src/lib/dto/auth/register.dto.ts", @@ -29967,6 +30220,16 @@ "to": "file:libs/domains/identity/backend/feature-auth/src/lib/guards/keycloak-roles.guard.ts", "type": "contains" }, + { + "from": "project:identity-backend-feature-auth", + "to": "file:libs/domains/identity/backend/feature-auth/src/lib/guards/pat-bearer-auth.guard.ts", + "type": "contains" + }, + { + "from": "project:identity-backend-feature-auth", + "to": "file:libs/domains/identity/backend/feature-auth/src/lib/guards/pat-scopes.guard.ts", + "type": "contains" + }, { "from": "project:identity-backend-feature-auth", "to": "file:libs/domains/identity/backend/feature-auth/src/lib/guards/users-auth.guard.ts", @@ -29982,6 +30245,11 @@ "to": "file:libs/domains/identity/backend/feature-auth/src/lib/modules/keycloak-user-sync.module.ts", "type": "contains" }, + { + "from": "project:identity-backend-feature-auth", + "to": "file:libs/domains/identity/backend/feature-auth/src/lib/modules/pat-auth.module.ts", + "type": "contains" + }, { "from": "project:identity-backend-feature-auth", "to": "file:libs/domains/identity/backend/feature-auth/src/lib/modules/users-auth.module.ts", @@ -29992,6 +30260,11 @@ "to": "file:libs/domains/identity/backend/feature-auth/src/lib/repositories/client-users.repository.ts", "type": "contains" }, + { + "from": "project:identity-backend-feature-auth", + "to": "file:libs/domains/identity/backend/feature-auth/src/lib/repositories/personal-access-tokens.repository.ts", + "type": "contains" + }, { "from": "project:identity-backend-feature-auth", "to": "file:libs/domains/identity/backend/feature-auth/src/lib/repositories/revoked-user-tokens.repository.ts", @@ -30017,6 +30290,11 @@ "to": "file:libs/domains/identity/backend/feature-auth/src/lib/services/keycloak-token.service.ts", "type": "contains" }, + { + "from": "project:identity-backend-feature-auth", + "to": "file:libs/domains/identity/backend/feature-auth/src/lib/services/personal-access-token.service.ts", + "type": "contains" + }, { "from": "project:identity-backend-feature-auth", "to": "file:libs/domains/identity/backend/feature-auth/src/lib/services/socket-auth.service.ts", @@ -30092,6 +30370,11 @@ "to": "file:libs/domains/identity/backend/util-auth/src/lib/entities/revoked-user-token.entity.ts", "type": "contains" }, + { + "from": "project:identity-backend-util-auth", + "to": "file:libs/domains/identity/backend/util-auth/src/lib/entities/user-personal-access-token.entity.ts", + "type": "contains" + }, { "from": "project:identity-backend-util-auth", "to": "file:libs/domains/identity/backend/util-auth/src/lib/entities/user.entity.ts", @@ -30577,6 +30860,11 @@ "to": "api:HTTP:POST:/auth/login", "type": "contains" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", + "to": "api:HTTP:POST:/auth/token", + "type": "contains" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", "to": "api:HTTP:POST:/auth/register", @@ -30607,6 +30895,41 @@ "to": "api:HTTP:POST:/auth/logout", "type": "contains" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", + "to": "api:HTTP:GET:/auth/token-scopes", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", + "to": "api:HTTP:GET:/auth/tokens", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", + "to": "api:HTTP:POST:/auth/tokens", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", + "to": "api:HTTP:PATCH:/auth/tokens/{id}", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", + "to": "api:HTTP:DELETE:/auth/tokens/{id}", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", + "to": "api:HTTP:GET:/users/{userId}/tokens", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", + "to": "api:HTTP:DELETE:/users/{userId}/tokens/{tokenId}", + "type": "contains" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", "to": "api:HTTP:GET:/users", @@ -31557,6 +31880,11 @@ "to": "api:HTTP:POST:/auth/login", "type": "contains" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:POST:/auth/token", + "type": "contains" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", "to": "api:HTTP:POST:/auth/register", @@ -31587,6 +31915,41 @@ "to": "api:HTTP:POST:/auth/logout", "type": "contains" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:GET:/auth/token-scopes", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:GET:/auth/tokens", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:POST:/auth/tokens", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:PATCH:/auth/tokens/{id}", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:DELETE:/auth/tokens/{id}", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:GET:/users/{userId}/tokens", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:DELETE:/users/{userId}/tokens/{tokenId}", + "type": "contains" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", "to": "api:HTTP:GET:/users", @@ -41862,6 +42225,16 @@ "to": "api:HTTP:POST:/auth/login", "type": "implements" }, + { + "from": "file:libs/domains/identity/backend/feature-auth/src/lib/controllers/auth.controller.ts", + "to": "api:HTTP:POST:/auth/token", + "type": "implements" + }, + { + "from": "project:identity-backend-feature-auth", + "to": "api:HTTP:POST:/auth/token", + "type": "implements" + }, { "from": "file:libs/domains/identity/backend/feature-auth/src/lib/controllers/auth.controller.ts", "to": "api:HTTP:POST:/auth/register", @@ -41922,6 +42295,141 @@ "to": "api:HTTP:POST:/auth/logout", "type": "implements" }, + { + "from": "file:libs/domains/identity/backend/feature-auth/src/lib/controllers/auth.controller.ts", + "to": "api:HTTP:GET:/auth/token-scopes", + "type": "implements" + }, + { + "from": "project:identity-backend-feature-auth", + "to": "api:HTTP:GET:/auth/token-scopes", + "type": "implements" + }, + { + "from": "file:libs/domains/identity/backend/feature-auth/src/lib/controllers/auth.controller.ts", + "to": "api:HTTP:GET:/auth/tokens", + "type": "implements" + }, + { + "from": "project:identity-backend-feature-auth", + "to": "api:HTTP:GET:/auth/tokens", + "type": "implements" + }, + { + "from": "file:libs/domains/identity/backend/feature-auth/src/lib/controllers/auth.controller.ts", + "to": "api:HTTP:POST:/auth/tokens", + "type": "implements" + }, + { + "from": "project:identity-backend-feature-auth", + "to": "api:HTTP:POST:/auth/tokens", + "type": "implements" + }, + { + "from": "file:libs/domains/identity/backend/feature-auth/src/lib/controllers/auth.controller.ts", + "to": "api:HTTP:PATCH:/auth/tokens/{id}", + "type": "implements" + }, + { + "from": "project:identity-backend-feature-auth", + "to": "api:HTTP:PATCH:/auth/tokens/{id}", + "type": "implements" + }, + { + "from": "file:libs/domains/identity/backend/feature-auth/src/lib/controllers/auth.controller.ts", + "to": "api:HTTP:DELETE:/auth/tokens/{id}", + "type": "implements" + }, + { + "from": "project:identity-backend-feature-auth", + "to": "api:HTTP:DELETE:/auth/tokens/{id}", + "type": "implements" + }, + { + "from": "file:libs/domains/identity/backend/feature-auth/src/lib/controllers/pat-token-exchange.controller.ts", + "to": "api:HTTP:POST:/auth/login", + "type": "implements" + }, + { + "from": "file:libs/domains/identity/backend/feature-auth/src/lib/controllers/pat-token-exchange.controller.ts", + "to": "api:HTTP:POST:/auth/token", + "type": "implements" + }, + { + "from": "file:libs/domains/identity/backend/feature-auth/src/lib/controllers/pat-token-exchange.controller.ts", + "to": "api:HTTP:POST:/auth/register", + "type": "implements" + }, + { + "from": "file:libs/domains/identity/backend/feature-auth/src/lib/controllers/pat-token-exchange.controller.ts", + "to": "api:HTTP:POST:/auth/confirm-email", + "type": "implements" + }, + { + "from": "file:libs/domains/identity/backend/feature-auth/src/lib/controllers/pat-token-exchange.controller.ts", + "to": "api:HTTP:POST:/auth/request-password-reset", + "type": "implements" + }, + { + "from": "file:libs/domains/identity/backend/feature-auth/src/lib/controllers/pat-token-exchange.controller.ts", + "to": "api:HTTP:POST:/auth/reset-password", + "type": "implements" + }, + { + "from": "file:libs/domains/identity/backend/feature-auth/src/lib/controllers/pat-token-exchange.controller.ts", + "to": "api:HTTP:POST:/auth/change-password", + "type": "implements" + }, + { + "from": "file:libs/domains/identity/backend/feature-auth/src/lib/controllers/pat-token-exchange.controller.ts", + "to": "api:HTTP:POST:/auth/logout", + "type": "implements" + }, + { + "from": "file:libs/domains/identity/backend/feature-auth/src/lib/controllers/pat-token-exchange.controller.ts", + "to": "api:HTTP:GET:/auth/token-scopes", + "type": "implements" + }, + { + "from": "file:libs/domains/identity/backend/feature-auth/src/lib/controllers/pat-token-exchange.controller.ts", + "to": "api:HTTP:GET:/auth/tokens", + "type": "implements" + }, + { + "from": "file:libs/domains/identity/backend/feature-auth/src/lib/controllers/pat-token-exchange.controller.ts", + "to": "api:HTTP:POST:/auth/tokens", + "type": "implements" + }, + { + "from": "file:libs/domains/identity/backend/feature-auth/src/lib/controllers/pat-token-exchange.controller.ts", + "to": "api:HTTP:PATCH:/auth/tokens/{id}", + "type": "implements" + }, + { + "from": "file:libs/domains/identity/backend/feature-auth/src/lib/controllers/pat-token-exchange.controller.ts", + "to": "api:HTTP:DELETE:/auth/tokens/{id}", + "type": "implements" + }, + { + "from": "file:libs/domains/identity/backend/feature-auth/src/lib/controllers/users.controller.ts", + "to": "api:HTTP:GET:/users/{userId}/tokens", + "type": "implements" + }, + { + "from": "project:identity-backend-feature-auth", + "to": "api:HTTP:GET:/users/{userId}/tokens", + "type": "implements" + }, + { + "from": "file:libs/domains/identity/backend/feature-auth/src/lib/controllers/users.controller.ts", + "to": "api:HTTP:DELETE:/users/{userId}/tokens/{tokenId}", + "type": "implements" + }, + { + "from": "project:identity-backend-feature-auth", + "to": "api:HTTP:DELETE:/users/{userId}/tokens/{tokenId}", + "type": "implements" + }, { "from": "file:libs/domains/identity/backend/feature-auth/src/lib/controllers/users.controller.ts", "to": "api:HTTP:GET:/users", @@ -43882,11 +44390,41 @@ "to": "api:HTTP:DELETE:/agents-filters/{id}", "type": "documents" }, + { + "from": "concept:agenstra-authentication-methods", + "to": "api:HTTP:POST:/auth/login", + "type": "documents" + }, + { + "from": "concept:agenstra-authentication-methods", + "to": "api:HTTP:POST:/auth/token", + "type": "documents" + }, { "from": "concept:agenstra-authentication-methods", "to": "api:HTTP:POST:/auth/register", "type": "documents" }, + { + "from": "concept:agenstra-authentication-methods", + "to": "api:HTTP:GET:/tickets", + "type": "documents" + }, + { + "from": "concept:agenstra-authentication-methods", + "to": "api:HTTP:POST:/tickets", + "type": "documents" + }, + { + "from": "concept:agenstra-authentication-methods", + "to": "api:HTTP:GET:/agents", + "type": "documents" + }, + { + "from": "concept:agenstra-authentication-methods", + "to": "api:HTTP:POST:/agents", + "type": "documents" + }, { "from": "concept:agenstra-users-authentication-flow", "to": "api:HTTP:POST:/auth/login", @@ -45242,11 +45780,56 @@ "to": "api:HTTP:POST:/invoices", "type": "documents" }, + { + "from": "concept:decabill-authentication-methods", + "to": "api:HTTP:POST:/auth/login", + "type": "documents" + }, + { + "from": "concept:decabill-authentication-methods", + "to": "api:HTTP:POST:/auth/token", + "type": "documents" + }, { "from": "concept:decabill-authentication-methods", "to": "api:HTTP:POST:/auth/register", "type": "documents" }, + { + "from": "concept:decabill-authentication-methods", + "to": "api:HTTP:GET:/tickets", + "type": "documents" + }, + { + "from": "concept:decabill-authentication-methods", + "to": "api:HTTP:POST:/tickets", + "type": "documents" + }, + { + "from": "concept:decabill-authentication-methods", + "to": "api:HTTP:GET:/subscriptions", + "type": "documents" + }, + { + "from": "concept:decabill-authentication-methods", + "to": "api:HTTP:POST:/subscriptions", + "type": "documents" + }, + { + "from": "concept:decabill-authentication-methods", + "to": "api:HTTP:GET:/projects", + "type": "documents" + }, + { + "from": "concept:decabill-authentication-methods", + "to": "api:HTTP:GET:/invoices", + "type": "documents" + }, + { + "from": "concept:decabill-authentication-methods", + "to": "api:HTTP:POST:/invoices", + "type": "documents" + }, { "from": "concept:decabill-users-authentication-flow", "to": "api:HTTP:POST:/auth/login", diff --git a/libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml b/libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml index a4a62a04b..120331a84 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml +++ b/libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml @@ -16,6 +16,9 @@ paths: /auth/login: post: summary: Login (users auth only) + description: | + Interactive console login with account password only. + Personal access tokens are not accepted; use POST /auth/token instead. operationId: login security: [] requestBody: @@ -33,6 +36,29 @@ paths: $ref: '#/components/schemas/LoginResponse' '401': description: Invalid credentials, email not confirmed, or account locked + /auth/token: + post: + summary: Exchange personal access token for machine JWT + description: | + Token-only exchange (no email). Returns a JWT with `amr: ["pat"]` and capability `scopes`. + Must not be used for console sessions. + operationId: exchangePersonalAccessToken + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExchangePersonalAccessTokenDto' + responses: + '200': + description: JWT access token, user info, and scopes + content: + application/json: + schema: + $ref: '#/components/schemas/PatLoginResponse' + '401': + description: Invalid, expired, or revoked token /auth/register: post: summary: Register new user (users auth only) @@ -139,6 +165,163 @@ paths: description: Session invalidated '401': description: Unauthorized + /auth/token-scopes: + get: + summary: List grantable PAT scopes for the current user + operationId: listPersonalAccessTokenScopes + responses: + '200': + description: Scope catalog filtered by role + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PersonalAccessTokenScopeDto' + '401': + description: Unauthorized + '403': + description: Password session required + /auth/tokens: + get: + summary: List own personal access tokens + operationId: listPersonalAccessTokens + responses: + '200': + description: Active tokens (no hashes) + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PersonalAccessTokenResponseDto' + '401': + description: Unauthorized + '403': + description: Password session required + post: + summary: Create a personal access token + operationId: createPersonalAccessToken + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreatePersonalAccessTokenDto' + responses: + '201': + description: Token created; plaintext returned once + content: + application/json: + schema: + $ref: '#/components/schemas/PersonalAccessTokenResponseDto' + '400': + description: Invalid scopes or name + '401': + description: Unauthorized + '403': + description: Password session required or cannot grant scopes + /auth/tokens/{id}: + patch: + summary: Update own personal access token name and scopes + operationId: updatePersonalAccessToken + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdatePersonalAccessTokenDto' + responses: + '200': + description: Token updated (secret unchanged) + content: + application/json: + schema: + $ref: '#/components/schemas/PersonalAccessTokenResponseDto' + '400': + description: Invalid scopes or name + '401': + description: Unauthorized + '403': + description: Password session required or cannot grant scopes + '404': + description: Token not found + delete: + summary: Revoke own personal access token + operationId: revokePersonalAccessToken + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + responses: + '204': + description: Revoked + '401': + description: Unauthorized + '403': + description: Password session required + '404': + description: Token not found + /users/{userId}/tokens: + get: + summary: List personal access tokens for a user (admin) + operationId: listUserPersonalAccessTokens + parameters: + - in: path + name: userId + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Tokens for user + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PersonalAccessTokenResponseDto' + '401': + description: Unauthorized + '403': + description: Admin + password session required + /users/{userId}/tokens/{tokenId}: + delete: + summary: Revoke a user's personal access token (admin) + operationId: revokeUserPersonalAccessToken + parameters: + - in: path + name: userId + required: true + schema: + type: string + format: uuid + - in: path + name: tokenId + required: true + schema: + type: string + format: uuid + responses: + '204': + description: Revoked + '401': + description: Unauthorized + '403': + description: Admin + password session required + '404': + description: Token not found /users: get: summary: List users (admin only) @@ -4152,6 +4335,90 @@ components: type: string role: $ref: '#/components/schemas/UserRole' + PatLoginResponse: + allOf: + - $ref: '#/components/schemas/LoginResponse' + - type: object + properties: + scopes: + type: array + items: + type: string + ExchangePersonalAccessTokenDto: + type: object + required: [token] + properties: + token: + type: string + description: Full personal access token (`fp_pat_…`) + CreatePersonalAccessTokenDto: + type: object + required: [name, scopes] + properties: + name: + type: string + minLength: 1 + maxLength: 255 + scopes: + type: array + minItems: 1 + items: + type: string + expiresAt: + type: string + format: date-time + UpdatePersonalAccessTokenDto: + type: object + required: [name, scopes] + properties: + name: + type: string + minLength: 1 + maxLength: 255 + scopes: + type: array + minItems: 1 + items: + type: string + PersonalAccessTokenScopeDto: + type: object + required: [scope] + properties: + scope: + type: string + PersonalAccessTokenResponseDto: + type: object + required: [id, name, tokenPrefix, scopes, createdAt] + properties: + id: + type: string + format: uuid + name: + type: string + tokenPrefix: + type: string + scopes: + type: array + items: + type: string + expiresAt: + type: string + format: date-time + nullable: true + revokedAt: + type: string + format: date-time + nullable: true + lastUsedAt: + type: string + format: date-time + nullable: true + createdAt: + type: string + format: date-time + token: + type: string + description: Plaintext token; only present on create RegisterResponse: type: object required: [user, message] diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/index.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/index.ts index ffc5131c6..756b1c299 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/index.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/index.ts @@ -141,5 +141,6 @@ export * from './lib/services/clients.service'; export * from './lib/services/tickets.service'; export * from './lib/services/statistics.service'; export * from './lib/notifications/agenstra-notification.events'; +export * from './lib/auth/agenstra-pat.scopes'; export * from './lib/notifications/agenstra-notification.publisher'; export * from './lib/utils/client-endpoint-security'; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.spec.ts new file mode 100644 index 000000000..ff87ef457 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.spec.ts @@ -0,0 +1,30 @@ +import { AGENSTRA_PAT_SCOPES } from './agenstra-pat.scopes'; + +describe('AGENSTRA_PAT_SCOPES', () => { + it('has unique entries', () => { + expect(new Set(AGENSTRA_PAT_SCOPES).size).toBe(AGENSTRA_PAT_SCOPES.length); + }); + + it('includes the full agenstra PAT scope catalog', () => { + expect(AGENSTRA_PAT_SCOPES).toEqual( + expect.arrayContaining([ + 'clients:read', + 'clients:write', + 'tickets:read', + 'tickets:write', + 'filter_rules:write', + 'agents:read', + 'agents:write', + 'agents:lifecycle', + 'agents:files', + 'agents:environment', + 'agents:vcs', + 'agents:deployments', + 'imports:write', + 'statistics:read', + 'users:admin', + 'webhooks:admin', + ]), + ); + }); +}); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.ts new file mode 100644 index 000000000..500741774 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.ts @@ -0,0 +1,27 @@ +import { IDENTITY_PAT_SCOPES } from '@forepath/identity/backend'; + +/** + * Agenstra PAT capability scopes (allowlist for create + JWT). + * Every entry must be enforced via `@RequireScopes` (or equivalent) on machine routes. + */ +export const AGENSTRA_PAT_SCOPES = [ + 'clients:read', + 'clients:write', + 'tickets:read', + 'tickets:write', + 'knowledge:read', + 'knowledge:write', + 'filter_rules:write', + 'agents:read', + 'agents:write', + 'agents:lifecycle', + 'agents:files', + 'agents:environment', + 'agents:vcs', + 'agents:deployments', + 'imports:write', + 'statistics:read', + ...IDENTITY_PAT_SCOPES, +] as const; + +export type AgenstraPatScope = (typeof AGENSTRA_PAT_SCOPES)[number]; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-agent-autonomy-directory.controller.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-agent-autonomy-directory.controller.ts index 7350650d7..a7d5d54c0 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-agent-autonomy-directory.controller.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-agent-autonomy-directory.controller.ts @@ -1,4 +1,4 @@ -import { type RequestWithUser } from '@forepath/identity/backend'; +import { RequireScopes, type RequestWithUser } from '@forepath/identity/backend'; import { Controller, Get, Param, ParseUUIDPipe, Req } from '@nestjs/common'; import { ClientAgentAutonomyService } from '../services/client-agent-autonomy.service'; @@ -8,6 +8,7 @@ export class ClientAgentAutonomyDirectoryController { constructor(private readonly clientAgentAutonomyService: ClientAgentAutonomyService) {} @Get('enabled-agent-ids') + @RequireScopes('agents:read') async listEnabledAgentIds(@Param('clientId', ParseUUIDPipe) clientId: string, @Req() req?: RequestWithUser) { return await this.clientAgentAutonomyService.listEnabledAgentIds(clientId, req); } diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-agent-autonomy.controller.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-agent-autonomy.controller.ts index f974cc660..871e476c3 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-agent-autonomy.controller.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-agent-autonomy.controller.ts @@ -1,4 +1,4 @@ -import { type RequestWithUser } from '@forepath/identity/backend'; +import { RequireScopes, type RequestWithUser } from '@forepath/identity/backend'; import { Body, Controller, Get, Param, ParseUUIDPipe, Put, Req } from '@nestjs/common'; import { UpsertClientAgentAutonomyDto } from '../dto/ticket-automation'; @@ -9,6 +9,7 @@ export class ClientAgentAutonomyController { constructor(private readonly clientAgentAutonomyService: ClientAgentAutonomyService) {} @Get() + @RequireScopes('agents:read') async get( @Param('clientId', ParseUUIDPipe) clientId: string, @Param('agentId', ParseUUIDPipe) agentId: string, @@ -18,6 +19,7 @@ export class ClientAgentAutonomyController { } @Put() + @RequireScopes('agents:write') async put( @Param('clientId', ParseUUIDPipe) clientId: string, @Param('agentId', ParseUUIDPipe) agentId: string, diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-statistics.controller.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-statistics.controller.ts index e8f1bf28f..dae40c8ca 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-statistics.controller.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-statistics.controller.ts @@ -1,4 +1,9 @@ -import { ClientUsersRepository, ensureClientAccess, type RequestWithUser } from '@forepath/identity/backend'; +import { + ClientUsersRepository, + ensureClientAccess, + RequireScopes, + type RequestWithUser, +} from '@forepath/identity/backend'; import { Controller, Get, Param, ParseIntPipe, ParseUUIDPipe, Query, Req } from '@nestjs/common'; import { ChatDirection } from '../entities/statistics-chat-io.entity'; @@ -11,6 +16,7 @@ import { StatisticsQueryService } from '../services/statistics-query.service'; * All endpoints require access to the client via ensureClientAccess. */ @Controller('clients/:clientId/statistics') +@RequireScopes('statistics:read') export class ClientStatisticsController { constructor( private readonly clientsRepository: ClientsRepository, diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients-agent-automation-proxy.controller.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients-agent-automation-proxy.controller.ts index e214bf6b5..68391f815 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients-agent-automation-proxy.controller.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients-agent-automation-proxy.controller.ts @@ -2,7 +2,12 @@ import { RunVerifierCommandsDto, RunVerifierCommandsResponseDto, } from '@forepath/agenstra/backend/feature-agent-manager'; -import { ClientUsersRepository, ensureClientAccess, type RequestWithUser } from '@forepath/identity/backend'; +import { + ClientUsersRepository, + ensureClientAccess, + RequireScopes, + type RequestWithUser, +} from '@forepath/identity/backend'; import { Body, Controller, Param, ParseUUIDPipe, Post, Req } from '@nestjs/common'; import { ClientsRepository } from '../repositories/clients.repository'; @@ -20,6 +25,7 @@ export class ClientsAgentAutomationProxyController { ) {} @Post('verify-commands') + @RequireScopes('agents:vcs') async verifyCommands( @Param('clientId', new ParseUUIDPipe({ version: '4' })) clientId: string, @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients-configuration-overrides.controller.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients-configuration-overrides.controller.ts index 478ceefe3..addbb0c00 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients-configuration-overrides.controller.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients-configuration-overrides.controller.ts @@ -4,6 +4,7 @@ import { } from '@forepath/agenstra/backend/feature-agent-manager'; import { ClientUsersRepository, + RequireScopes, ensureWorkspaceManagementAccess, type RequestWithUser, } from '@forepath/identity/backend'; @@ -21,6 +22,7 @@ export class ClientsConfigurationOverridesController { ) {} @Get() + @RequireScopes('clients:read') async getConfigurationOverrides( @Param('id', new ParseUUIDPipe({ version: '4' })) clientId: string, @Req() req?: RequestWithUser, @@ -31,6 +33,7 @@ export class ClientsConfigurationOverridesController { } @Put(':settingKey') + @RequireScopes('clients:write') async upsertConfigurationOverride( @Param('id', new ParseUUIDPipe({ version: '4' })) clientId: string, @Param('settingKey') settingKey: string, @@ -44,6 +47,7 @@ export class ClientsConfigurationOverridesController { @Delete(':settingKey') @HttpCode(HttpStatus.NO_CONTENT) + @RequireScopes('clients:write') async deleteConfigurationOverride( @Param('id', new ParseUUIDPipe({ version: '4' })) clientId: string, @Param('settingKey') settingKey: string, diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients-deployments.controller.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients-deployments.controller.ts index c0c3a8ffe..4ed34b7df 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients-deployments.controller.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients-deployments.controller.ts @@ -1,4 +1,9 @@ -import { ClientUsersRepository, ensureClientAccess, type RequestWithUser } from '@forepath/identity/backend'; +import { + ClientUsersRepository, + ensureClientAccess, + RequireScopes, + type RequestWithUser, +} from '@forepath/identity/backend'; import { Body, Controller, @@ -22,6 +27,7 @@ import { ClientAgentDeploymentsProxyService } from '../services/client-agent-dep * Proxies requests to remote agent-manager services for deployment operations. */ @Controller('clients/:id/agents/:agentId/deployments') +@RequireScopes('agents:deployments') export class ClientsDeploymentsController { constructor( private readonly proxyService: ClientAgentDeploymentsProxyService, diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients-vcs.controller.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients-vcs.controller.ts index 05b47a0ab..c0fd3ed17 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients-vcs.controller.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients-vcs.controller.ts @@ -11,7 +11,12 @@ import { StageFilesDto, UnstageFilesDto, } from '@forepath/agenstra/backend/feature-agent-manager'; -import { ClientUsersRepository, ensureClientAccess, type RequestWithUser } from '@forepath/identity/backend'; +import { + ClientUsersRepository, + ensureClientAccess, + RequireScopes, + type RequestWithUser, +} from '@forepath/identity/backend'; import { BadRequestException, Body, @@ -35,6 +40,7 @@ import { ClientAgentVcsProxyService } from '../services/client-agent-vcs-proxy.s * Provides endpoints that proxy VCS requests to client endpoints. */ @Controller('clients/:clientId/agents/:agentId/vcs') +@RequireScopes('agents:vcs') export class ClientsVcsController { constructor( private readonly clientAgentVcsProxyService: ClientAgentVcsProxyService, diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts index e3d07a1db..90e27ad0c 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts @@ -212,7 +212,7 @@ describe('ClientsController', () => { const result = await controller.getClients(10, 0, mockReq); expect(result).toEqual(clients); - expect(service.findAll).toHaveBeenCalledWith(10, 0, undefined, undefined, true); + expect(service.findAll).toHaveBeenCalledWith(10, 0, undefined, undefined, true, { amr: undefined }); }); it('should use default pagination values', async () => { @@ -224,7 +224,7 @@ describe('ClientsController', () => { const result = await controller.getClients(undefined, undefined, mockReq); expect(result).toEqual(clients); - expect(service.findAll).toHaveBeenCalledWith(10, 0, undefined, undefined, true); + expect(service.findAll).toHaveBeenCalledWith(10, 0, undefined, undefined, true, { amr: undefined }); }); }); @@ -237,7 +237,7 @@ describe('ClientsController', () => { const result = await controller.getClient('test-uuid', mockReq); expect(result).toEqual(mockClientResponse); - expect(service.findOne).toHaveBeenCalledWith('test-uuid', undefined, undefined, true); + expect(service.findOne).toHaveBeenCalledWith('test-uuid', undefined, undefined, true, { amr: undefined }); }); }); @@ -300,7 +300,9 @@ describe('ClientsController', () => { const result = await controller.updateClient('test-uuid', updateDto, mockReq); expect(result).toEqual(mockClientResponse); - expect(service.update).toHaveBeenCalledWith('test-uuid', updateDto, undefined, undefined, true); + expect(service.update).toHaveBeenCalledWith('test-uuid', updateDto, undefined, undefined, true, { + amr: undefined, + }); }); }); @@ -1226,6 +1228,7 @@ describe('ClientsController', () => { UserRole.USER, false, undefined, + { amr: undefined }, ); }); @@ -1262,6 +1265,7 @@ describe('ClientsController', () => { UserRole.USER, false, undefined, + { amr: undefined }, ); }); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts index c019b42a3..157b2aba2 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts @@ -24,6 +24,7 @@ import { ensureClientAccess, ensureWorkspaceManagementAccess, getUserFromRequest, + RequireScopes, type RequestWithUser, UserRole, } from '@forepath/identity/backend'; @@ -87,6 +88,7 @@ export class ClientsController { * @returns Array of client response DTOs */ @Get() + @RequireScopes('clients:read') async getClients( @Query('limit', new ParseIntPipe({ optional: true })) limit?: number, @Query('offset', new ParseIntPipe({ optional: true })) offset?: number, @@ -100,6 +102,7 @@ export class ClientsController { userInfo.userId, userInfo.userRole, userInfo.isApiKeyAuth, + { amr: userInfo.amr }, ); } @@ -112,6 +115,7 @@ export class ClientsController { * @returns The created client response DTO with generated API key (if applicable) */ @Post() + @RequireScopes('clients:write') async createClient( @Body() createClientDto: CreateClientDto, @Req() req?: RequestWithUser, @@ -131,6 +135,7 @@ export class ClientsController { * @returns Array of agent response DTOs */ @Get(':id/agents') + @RequireScopes('agents:read') async getClientAgents( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Query('limit', new ParseIntPipe({ optional: true })) limit?: number, @@ -145,6 +150,7 @@ export class ClientsController { userInfo.userId, userInfo.userRole, userInfo.isApiKeyAuth, + { amr: userInfo.amr }, ); if (!access.hasAccess) { @@ -164,6 +170,7 @@ export class ClientsController { * @returns Map of model id to display name */ @Get(':id/agents/:agentId/models') + @RequireScopes('agents:read') async listClientAgentModels( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @@ -177,6 +184,7 @@ export class ClientsController { userInfo.userId, userInfo.userRole, userInfo.isApiKeyAuth, + { amr: userInfo.amr }, ); if (!access.hasAccess) { @@ -195,6 +203,7 @@ export class ClientsController { * @returns The agent response DTO */ @Get(':id/agents/:agentId') + @RequireScopes('agents:read') async getClientAgent( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @@ -208,6 +217,7 @@ export class ClientsController { userInfo.userId, userInfo.userRole, userInfo.isApiKeyAuth, + { amr: userInfo.amr }, ); if (!access.hasAccess) { @@ -227,6 +237,7 @@ export class ClientsController { * @returns The updated agent response DTO */ @Post(':id/agents/:agentId') + @RequireScopes('agents:write') async updateClientAgent( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @@ -249,6 +260,7 @@ export class ClientsController { * @returns The created agent response DTO with generated password */ @Post(':id/agents') + @RequireScopes('agents:write') async createClientAgent( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Body() createAgentDto: CreateAgentDto, @@ -269,6 +281,7 @@ export class ClientsController { */ @Delete(':id/agents/:agentId') @HttpCode(HttpStatus.NO_CONTENT) + @RequireScopes('agents:write') async deleteClientAgent( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @@ -289,6 +302,7 @@ export class ClientsController { * @returns The agent response DTO */ @Post(':id/agents/:agentId/start') + @RequireScopes('agents:lifecycle') async startClientAgent( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @@ -308,6 +322,7 @@ export class ClientsController { * @returns The agent response DTO */ @Post(':id/agents/:agentId/stop') + @RequireScopes('agents:lifecycle') async stopClientAgent( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @@ -327,6 +342,7 @@ export class ClientsController { * @returns The agent response DTO */ @Post(':id/agents/:agentId/restart') + @RequireScopes('agents:lifecycle') async restartClientAgent( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @@ -345,13 +361,16 @@ export class ClientsController { * @returns The client response DTO */ @Get(':id') + @RequireScopes('clients:read') async getClient( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Req() req?: RequestWithUser, ): Promise { const userInfo = getUserFromRequest(req || ({} as RequestWithUser)); - return await this.clientsService.findOne(id, userInfo.userId, userInfo.userRole, userInfo.isApiKeyAuth); + return await this.clientsService.findOne(id, userInfo.userId, userInfo.userRole, userInfo.isApiKeyAuth, { + amr: userInfo.amr, + }); } /** @@ -363,6 +382,7 @@ export class ClientsController { * @returns The updated client response DTO */ @Post(':id') + @RequireScopes('clients:write') async updateClient( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Body() updateClientDto: UpdateClientDto, @@ -376,6 +396,7 @@ export class ClientsController { userInfo.userId, userInfo.userRole, userInfo.isApiKeyAuth, + { amr: userInfo.amr }, ); } @@ -388,6 +409,7 @@ export class ClientsController { */ @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) + @RequireScopes('clients:write') async deleteClient( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Req() req?: RequestWithUser, @@ -447,6 +469,7 @@ export class ClientsController { * @returns File content (base64-encoded) and encoding type */ @Get(':id/agents/:agentId/files/*path') + @RequireScopes('agents:files') async readFile( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @@ -483,6 +506,7 @@ export class ClientsController { */ @Put(':id/agents/:agentId/files/*path') @HttpCode(HttpStatus.NO_CONTENT) + @RequireScopes('agents:files') async writeFile( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @@ -521,6 +545,7 @@ export class ClientsController { * @returns Array of file nodes */ @Get(':id/agents/:agentId/files') + @RequireScopes('agents:files') async listDirectory( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @@ -544,6 +569,7 @@ export class ClientsController { */ @Post(':id/agents/:agentId/files/*path') @HttpCode(HttpStatus.CREATED) + @RequireScopes('agents:files') async createFileOrDirectory( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @@ -588,6 +614,7 @@ export class ClientsController { */ @Delete(':id/agents/:agentId/files/*path') @HttpCode(HttpStatus.NO_CONTENT) + @RequireScopes('agents:files') async deleteFileOrDirectory( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @@ -626,6 +653,7 @@ export class ClientsController { */ @Patch(':id/agents/:agentId/files/*path') @HttpCode(HttpStatus.NO_CONTENT) + @RequireScopes('agents:files') async moveFileOrDirectory( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @@ -663,6 +691,7 @@ export class ClientsController { * @returns Array of provider information */ @Get('provisioning/providers') + @RequireScopes('clients:read') async getProvisioningProviders(): Promise> { return this.provisioningProviderFactory.getAllProviders().map((provider) => ({ type: provider.getType(), @@ -676,6 +705,7 @@ export class ClientsController { * @returns Array of server types with specifications and pricing */ @Get('provisioning/providers/:providerType/server-types') + @RequireScopes('clients:read') async getServerTypes(@Param('providerType') providerType: string) { if (!this.provisioningProviderFactory.hasProvider(providerType)) { throw new BadRequestException( @@ -694,6 +724,7 @@ export class ClientsController { * @returns Array of locations/regions with human-readable labels */ @Get('provisioning/providers/:providerType/locations') + @RequireScopes('clients:read') async getLocations(@Param('providerType') providerType: string) { if (!this.provisioningProviderFactory.hasProvider(providerType)) { throw new BadRequestException( @@ -714,6 +745,7 @@ export class ClientsController { * @returns Provisioned server response with client information */ @Post('provisioning/provision') + @RequireScopes('clients:write') async provisionServer( @Body() provisionServerDto: ProvisionServerDto, @Req() req?: RequestWithUser, @@ -736,6 +768,7 @@ export class ClientsController { * @returns Server information */ @Get(':id/provisioning/info') + @RequireScopes('clients:read') async getServerInfo(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Req() req?: RequestWithUser) { await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, id, req); @@ -750,6 +783,7 @@ export class ClientsController { */ @Delete(':id/provisioning') @HttpCode(HttpStatus.NO_CONTENT) + @RequireScopes('clients:write') async deleteProvisionedServer( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Req() req?: RequestWithUser, @@ -771,6 +805,7 @@ export class ClientsController { * @returns Array of environment variable response DTOs */ @Get(':id/agents/:agentId/environment') + @RequireScopes('agents:environment') async getClientAgentEnvironmentVariables( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @@ -797,6 +832,7 @@ export class ClientsController { * @returns Count of environment variables */ @Get(':id/agents/:agentId/environment/count') + @RequireScopes('agents:environment') async countClientAgentEnvironmentVariables( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @@ -818,6 +854,7 @@ export class ClientsController { */ @Post(':id/agents/:agentId/environment') @HttpCode(HttpStatus.CREATED) + @RequireScopes('agents:environment') async createClientAgentEnvironmentVariable( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @@ -840,6 +877,7 @@ export class ClientsController { * @returns The updated environment variable response DTO */ @Put(':id/agents/:agentId/environment/:envVarId') + @RequireScopes('agents:environment') async updateClientAgentEnvironmentVariable( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @@ -867,6 +905,7 @@ export class ClientsController { */ @Delete(':id/agents/:agentId/environment/:envVarId') @HttpCode(HttpStatus.NO_CONTENT) + @RequireScopes('agents:environment') async deleteClientAgentEnvironmentVariable( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @@ -887,6 +926,7 @@ export class ClientsController { */ @Delete(':id/agents/:agentId/environment') @HttpCode(HttpStatus.OK) + @RequireScopes('agents:environment') async deleteAllClientAgentEnvironmentVariables( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @@ -905,6 +945,7 @@ export class ClientsController { * @returns Array of client-user relationships */ @Get(':id/users') + @RequireScopes('clients:read') async getClientUsers( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Req() req?: RequestWithUser, @@ -917,6 +958,7 @@ export class ClientsController { userInfo.userId, userInfo.userRole, userInfo.isApiKeyAuth, + { amr: userInfo.amr }, ); if (!access.hasAccess) { @@ -936,6 +978,7 @@ export class ClientsController { */ @Post(':id/users') @HttpCode(HttpStatus.CREATED) + @RequireScopes('clients:write') async addClientUser( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Body() addClientUserDto: AddClientUserDto, @@ -949,6 +992,7 @@ export class ClientsController { userInfo.userId, userInfo.userRole, userInfo.isApiKeyAuth, + { amr: userInfo.amr }, ); if (!access.hasAccess) { @@ -965,6 +1009,7 @@ export class ClientsController { userInfo.userRole || UserRole.USER, isClientCreator, access.clientUserRole, + { amr: userInfo.amr }, ); } @@ -977,6 +1022,7 @@ export class ClientsController { */ @Delete(':id/users/:relationshipId') @HttpCode(HttpStatus.NO_CONTENT) + @RequireScopes('clients:write') async removeClientUser( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Param('relationshipId', new ParseUUIDPipe({ version: '4' })) relationshipId: string, @@ -990,6 +1036,7 @@ export class ClientsController { userInfo.userId, userInfo.userRole, userInfo.isApiKeyAuth, + { amr: userInfo.amr }, ); if (!access.hasAccess) { @@ -1006,6 +1053,7 @@ export class ClientsController { userInfo.userRole || UserRole.USER, isClientCreator, access.clientUserRole, + { amr: userInfo.amr }, ); } } diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/context-import.controller.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/context-import.controller.spec.ts index 6e72f3eec..42e7871e7 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/context-import.controller.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/context-import.controller.spec.ts @@ -87,6 +87,19 @@ describe('ContextImportController', () => { expect(connections.findAll).toHaveBeenCalledWith(10, 0); }); + + it('allows PAT admin sessions through assertAdmin and relies on RequireScopes for PAT scope enforcement', async () => { + getUserFromRequestMock.mockReturnValueOnce({ + userId: 'user-1', + userRole: identity.UserRole.ADMIN, + isApiKeyAuth: false, + amr: ['pat'], + } as identity.UserInfoFromRequest); + (connections.findAll as jest.Mock).mockResolvedValue([]); + + await controller.listConnections(adminReq); + expect(connections.findAll).toHaveBeenCalledWith(10, 0); + }); }); describe('listConnections pagination', () => { diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/context-import.controller.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/context-import.controller.ts index b1d9466c8..201386b08 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/context-import.controller.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/context-import.controller.ts @@ -3,7 +3,7 @@ * Encrypted Atlassian tokens are never returned from list/get connection APIs. The scheduler runs only * configs loaded from the database and does not accept arbitrary client IDs from HTTP. */ -import { UserRole, getUserFromRequest, type RequestWithUser } from '@forepath/identity/backend'; +import { RequireScopes, UserRole, getUserFromRequest, type RequestWithUser } from '@forepath/identity/backend'; import { Body, Controller, @@ -34,6 +34,7 @@ import { ExternalImportConfigService } from '../services/external-import-config. import { ExternalImportSyncMarkerService } from '../services/external-import-sync-marker.service'; @Controller('imports/atlassian') +@RequireScopes('imports:write') export class ContextImportController { constructor( private readonly connections: AtlassianSiteConnectionService, @@ -46,7 +47,11 @@ export class ContextImportController { private assertAdmin(req?: RequestWithUser): void { const u = getUserFromRequest(req || ({} as RequestWithUser)); - if (!u.isApiKeyAuth && u.userRole !== UserRole.ADMIN) { + if (u.isApiKeyAuth) { + return; + } + + if (u.userRole !== UserRole.ADMIN) { throw new ForbiddenException('Admin only'); } } diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/filter-rules.controller.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/filter-rules.controller.spec.ts index 3faf43fa8..484b5229e 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/filter-rules.controller.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/filter-rules.controller.spec.ts @@ -74,6 +74,18 @@ describe('FilterRulesController', () => { await controller.list(adminReq); expect(mockService.findAll).toHaveBeenCalled(); }); + + it('allows PAT admin sessions through assertAdmin and relies on RequireScopes for PAT scope enforcement', async () => { + getUserFromRequestMock.mockReturnValueOnce({ + userId: 'user-1', + userRole: identity.UserRole.ADMIN, + isApiKeyAuth: false, + amr: ['pat'], + } as identity.UserInfoFromRequest); + (mockService.findAll as jest.Mock).mockResolvedValue([]); + await controller.list(adminReq); + expect(mockService.findAll).toHaveBeenCalled(); + }); }); describe('list', () => { diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/filter-rules.controller.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/filter-rules.controller.ts index 2ac8bd229..2549525cf 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/filter-rules.controller.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/filter-rules.controller.ts @@ -1,4 +1,4 @@ -import { UserRole, getUserFromRequest, type RequestWithUser } from '@forepath/identity/backend'; +import { RequireScopes, UserRole, getUserFromRequest, type RequestWithUser } from '@forepath/identity/backend'; import { Body, Controller, @@ -22,13 +22,18 @@ import { UpdateFilterRuleDto } from '../dto/filter-rules/update-filter-rule.dto' import { FilterRulesService } from '../services/filter-rules.service'; @Controller('filter-rules') +@RequireScopes('filter_rules:write') export class FilterRulesController { constructor(private readonly filterRulesService: FilterRulesService) {} private assertAdmin(req?: RequestWithUser): void { const u = getUserFromRequest(req || ({} as RequestWithUser)); - if (!u.isApiKeyAuth && u.userRole !== UserRole.ADMIN) { + if (u.isApiKeyAuth) { + return; + } + + if (u.userRole !== UserRole.ADMIN) { throw new ForbiddenException('Admin only'); } } diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/knowledge-tree.controller.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/knowledge-tree.controller.ts index 0652faa38..9e124d64e 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/knowledge-tree.controller.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/knowledge-tree.controller.ts @@ -1,4 +1,4 @@ -import { type RequestWithUser } from '@forepath/identity/backend'; +import { RequireScopes, type RequestWithUser } from '@forepath/identity/backend'; import { Body, Controller, @@ -30,16 +30,19 @@ import { KnowledgeTreeService } from '../services/knowledge-tree.service'; export class KnowledgeTreeController { constructor(private readonly knowledgeTreeService: KnowledgeTreeService) {} + @RequireScopes('knowledge:read') @Get() async listByClient(@Query('clientId', ParseUUIDPipe) clientId: string, @Req() req?: RequestWithUser) { return await this.knowledgeTreeService.listNodes(clientId, req); } + @RequireScopes('knowledge:read') @Get('tree') async getTree(@Query('clientId', ParseUUIDPipe) clientId: string, @Req() req?: RequestWithUser) { return await this.knowledgeTreeService.getTree(clientId, req); } + @RequireScopes('knowledge:read') @Get('by-sha') async getBySha( @Query('clientId', ParseUUIDPipe) clientId: string, @@ -49,18 +52,21 @@ export class KnowledgeTreeController { return await this.knowledgeTreeService.findNodeBySha(clientId, sha, req); } + @RequireScopes('knowledge:write') @Post() @HttpCode(HttpStatus.CREATED) async createNode(@Body() dto: CreateKnowledgeNodeDto, @Req() req?: RequestWithUser) { return await this.knowledgeTreeService.createNode(dto, req); } + @RequireScopes('knowledge:write') @Post('relations') @HttpCode(HttpStatus.CREATED) async createRelation(@Body() dto: CreateKnowledgeRelationDto, @Req() req?: RequestWithUser) { return await this.knowledgeTreeService.createRelation(dto, req); } + @RequireScopes('knowledge:read') @Get('relations') async listRelations( @Query('clientId', ParseUUIDPipe) clientId: string, @@ -71,12 +77,14 @@ export class KnowledgeTreeController { return await this.knowledgeTreeService.listRelations(clientId, sourceType, sourceId, req); } + @RequireScopes('knowledge:write') @Delete('relations/:id') @HttpCode(HttpStatus.NO_CONTENT) async deleteRelation(@Param('id', ParseUUIDPipe) id: string, @Req() req?: RequestWithUser) { await this.knowledgeTreeService.deleteRelation(id, req); } + @RequireScopes('knowledge:read') @Get('relations/prompt-context') async getPromptContexts( @Query('clientId', ParseUUIDPipe) clientId: string, @@ -87,11 +95,13 @@ export class KnowledgeTreeController { return await this.knowledgeTreeService.collectPromptContextsForSource(clientId, sourceType, sourceId, req); } + @RequireScopes('knowledge:read') @Get(':id') async getNode(@Param('id', ParseUUIDPipe) id: string, @Req() req?: RequestWithUser) { return await this.knowledgeTreeService.getNode(id, req); } + @RequireScopes('knowledge:read') @Get(':id/activity') async listActivity( @Param('id', ParseUUIDPipe) id: string, @@ -102,6 +112,7 @@ export class KnowledgeTreeController { return await this.knowledgeTreeService.listPageActivity(id, limit, offset, req); } + @RequireScopes('knowledge:write') @Patch(':id') async updateNode( @Param('id', ParseUUIDPipe) id: string, @@ -111,6 +122,7 @@ export class KnowledgeTreeController { return await this.knowledgeTreeService.updateNode(id, dto, req); } + @RequireScopes('knowledge:write') @Post(':id/reorder') async reorderNode( @Param('id', ParseUUIDPipe) id: string, @@ -120,11 +132,13 @@ export class KnowledgeTreeController { return await this.knowledgeTreeService.reorderNode(id, dto, req); } + @RequireScopes('knowledge:write') @Post(':id/duplicate') async duplicateNode(@Param('id', ParseUUIDPipe) id: string, @Req() req?: RequestWithUser) { return await this.knowledgeTreeService.duplicateNode(id, req); } + @RequireScopes('knowledge:write') @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) async deleteNode( diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/statistics.controller.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/statistics.controller.ts index 9d4b00da3..075b14c2c 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/statistics.controller.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/statistics.controller.ts @@ -1,4 +1,4 @@ -import { getUserFromRequest, type RequestWithUser } from '@forepath/identity/backend'; +import { RequireScopes, getUserFromRequest, type RequestWithUser } from '@forepath/identity/backend'; import { Controller, ForbiddenException, Get, ParseIntPipe, Query, Req } from '@nestjs/common'; import { ChatDirection } from '../entities/statistics-chat-io.entity'; @@ -12,6 +12,7 @@ import { StatisticsQueryService } from '../services/statistics-query.service'; * When clientId query param is provided, validates the user has access to that client. */ @Controller('statistics') +@RequireScopes('statistics:read') export class StatisticsController { constructor( private readonly clientsService: ClientsService, @@ -24,6 +25,7 @@ export class StatisticsController { userInfo.userId, userInfo.userRole, userInfo.isApiKeyAuth, + { amr: userInfo.amr }, ); if (clientId) { diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/ticket-automation.controller.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/ticket-automation.controller.ts index 395fdf189..d9d27a25f 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/ticket-automation.controller.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/ticket-automation.controller.ts @@ -1,4 +1,4 @@ -import { type RequestWithUser } from '@forepath/identity/backend'; +import { RequireScopes, type RequestWithUser } from '@forepath/identity/backend'; import { Body, Controller, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Req } from '@nestjs/common'; import { UpdateTicketAutomationDto } from '../dto/ticket-automation'; @@ -9,11 +9,13 @@ export class TicketAutomationController { constructor(private readonly ticketAutomationService: TicketAutomationService) {} @Get() + @RequireScopes('tickets:read') async get(@Param('ticketId', ParseUUIDPipe) ticketId: string, @Req() req?: RequestWithUser) { return await this.ticketAutomationService.getAutomation(ticketId, req); } @Patch() + @RequireScopes('tickets:write') async patch( @Param('ticketId', ParseUUIDPipe) ticketId: string, @Body() dto: UpdateTicketAutomationDto, @@ -24,22 +26,26 @@ export class TicketAutomationController { @Post('approve') @HttpCode(HttpStatus.OK) + @RequireScopes('tickets:write') async approve(@Param('ticketId', ParseUUIDPipe) ticketId: string, @Req() req?: RequestWithUser) { return await this.ticketAutomationService.approve(ticketId, req); } @Post('unapprove') @HttpCode(HttpStatus.OK) + @RequireScopes('tickets:write') async unapprove(@Param('ticketId', ParseUUIDPipe) ticketId: string, @Req() req?: RequestWithUser) { return await this.ticketAutomationService.unapprove(ticketId, req); } @Get('runs') + @RequireScopes('tickets:read') async listRuns(@Param('ticketId', ParseUUIDPipe) ticketId: string, @Req() req?: RequestWithUser) { return await this.ticketAutomationService.listRuns(ticketId, req); } @Get('runs/:runId') + @RequireScopes('tickets:read') async getRun( @Param('ticketId', ParseUUIDPipe) ticketId: string, @Param('runId', ParseUUIDPipe) runId: string, @@ -50,6 +56,7 @@ export class TicketAutomationController { @Post('runs/:runId/cancel') @HttpCode(HttpStatus.OK) + @RequireScopes('tickets:write') async cancelRun( @Param('ticketId', ParseUUIDPipe) ticketId: string, @Param('runId', ParseUUIDPipe) runId: string, diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/tickets.controller.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/tickets.controller.ts index b2ff715e9..51ba075be 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/tickets.controller.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/tickets.controller.ts @@ -1,4 +1,4 @@ -import { type RequestWithUser } from '@forepath/identity/backend'; +import { RequireScopes, type RequestWithUser } from '@forepath/identity/backend'; import { Body, Controller, @@ -32,6 +32,7 @@ export class TicketsController { constructor(private readonly ticketsService: TicketsService) {} @Get() + @RequireScopes('tickets:read') async list( @Query('clientId', new ParseUUIDPipe({ optional: true })) clientId?: string, @Query('status') status?: TicketStatus, @@ -51,21 +52,25 @@ export class TicketsController { @Post() @HttpCode(HttpStatus.CREATED) + @RequireScopes('tickets:write') async create(@Body() dto: CreateTicketDto, @Req() req?: RequestWithUser) { return await this.ticketsService.create(dto, req); } @Get(':id/prototype-prompt') + @RequireScopes('tickets:read') async prototypePrompt(@Param('id', ParseUUIDPipe) id: string, @Req() req?: RequestWithUser) { return await this.ticketsService.getPrototypePrompt(id, req); } @Get(':id/comments') + @RequireScopes('tickets:read') async listComments(@Param('id', ParseUUIDPipe) id: string, @Req() req?: RequestWithUser) { return await this.ticketsService.listComments(id, req); } @Post(':id/comments') + @RequireScopes('tickets:write') async addComment( @Param('id', ParseUUIDPipe) id: string, @Body() dto: CreateTicketCommentDto, @@ -75,6 +80,7 @@ export class TicketsController { } @Get(':id/activity') + @RequireScopes('tickets:read') async listActivity( @Param('id', ParseUUIDPipe) id: string, @Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit: number, @@ -85,6 +91,7 @@ export class TicketsController { } @Post(':id/body-generation-sessions') + @RequireScopes('tickets:write') async startBodySession( @Param('id', ParseUUIDPipe) id: string, @Body() dto: StartBodyGenerationSessionDto, @@ -94,6 +101,7 @@ export class TicketsController { } @Post(':id/apply-generated-body') + @RequireScopes('tickets:write') async applyGeneratedBody( @Param('id', ParseUUIDPipe) id: string, @Body() dto: ApplyGeneratedBodyDto, @@ -103,11 +111,13 @@ export class TicketsController { } @Post(':id/migrate') + @RequireScopes('tickets:write') async migrate(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MigrateTicketDto, @Req() req?: RequestWithUser) { return await this.ticketsService.migrateTicket(id, dto, req); } @Get(':id') + @RequireScopes('tickets:read') async getOne( @Param('id', ParseUUIDPipe) id: string, @Query('includeDescendants') includeDescendants?: string, @@ -119,12 +129,14 @@ export class TicketsController { } @Patch(':id') + @RequireScopes('tickets:write') async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTicketDto, @Req() req?: RequestWithUser) { return await this.ticketsService.update(id, dto, req); } @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) + @RequireScopes('tickets:write') async remove( @Param('id', ParseUUIDPipe) id: string, @Query('releaseExternalSyncMarker') releaseExternalSyncMarkerRaw?: string, diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts index de1d00020..8bfb3b4ac 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts @@ -9,6 +9,7 @@ import { RevokedUserTokenEntity, SocketAuthService, UserEntity, + UserPersonalAccessTokenEntity, UsersRepository, } from '@forepath/identity/backend'; import { Module } from '@nestjs/common'; @@ -155,6 +156,8 @@ describe('ClientsModule', () => { .useValue(mockRepository) .overrideProvider(getRepositoryToken(RevokedUserTokenEntity)) .useValue(mockRepository) + .overrideProvider(getRepositoryToken(UserPersonalAccessTokenEntity)) + .useValue(mockRepository) .overrideProvider(getRepositoryToken(StatisticsUserEntity)) .useValue(mockRepository) .overrideProvider(getRepositoryToken(StatisticsClientEntity)) diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts index e8bb1e8b3..6bae54c3c 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts @@ -18,6 +18,7 @@ import { RevokedUserTokensRepository, SocketAuthService, UserEntity, + UserPersonalAccessTokenEntity, UsersRepository, } from '@forepath/identity/backend'; import { forwardRef, Module } from '@nestjs/common'; @@ -109,6 +110,7 @@ const authMethod = getAuthenticationMethod(); ClientUserEntity, UserEntity, RevokedUserTokenEntity, + UserPersonalAccessTokenEntity, TicketEntity, TicketCommentEntity, TicketActivityEntity, diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/assert-notification-admin.util.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/assert-notification-admin.util.ts index 6166012f4..77f8c4fe3 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/assert-notification-admin.util.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/assert-notification-admin.util.ts @@ -1,4 +1,4 @@ -import { UserRole, getUserFromRequest, type RequestWithUser } from '@forepath/identity/backend'; +import { UserRole, assertPatScopes, getUserFromRequest, type RequestWithUser } from '@forepath/identity/backend'; import { ForbiddenException } from '@nestjs/common'; import type { Request } from 'express'; @@ -8,4 +8,6 @@ export function assertNotificationAdmin(req: Request): void { if (!u.isApiKeyAuth && u.userRole !== UserRole.ADMIN) { throw new ForbiddenException('Admin only'); } + + assertPatScopes(u, 'webhooks:admin'); } diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agent-console-status.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agent-console-status.service.ts index 72203ec6a..5572f716c 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agent-console-status.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agent-console-status.service.ts @@ -147,6 +147,7 @@ export class AgentConsoleStatusService { userInfo.userId ?? userId, userInfo.userRole, userInfo.isApiKeyAuth, + { amr: userInfo.amr }, ); const environments = await this.buildEnvironmentsForUser(userId, clientIds); const clients = rollupClients(environments); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/clients.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/clients.service.ts index 9ce349f6d..c487d293e 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/clients.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/clients.service.ts @@ -147,11 +147,12 @@ export class ClientsService { userId?: string, userRole?: UserRole, isApiKeyAuth = false, + options?: { amr?: string[] }, ): Promise { // In api-key mode, return all clients if (isApiKeyAuth || this.isApiKeyMode()) { const clients = await this.clientsRepository.findAll(limit, offset); - const viewer = { userId, userRole, isApiKeyAuth: true }; + const viewer = { userId, userRole, isApiKeyAuth: true, amr: options?.amr }; return Promise.all( clients.map(async (client) => { @@ -173,10 +174,10 @@ export class ClientsService { return []; } - // Global admin: return all clients - if (userRole === UserRole.ADMIN) { + // Global console admin: return all clients (PAT must not inherit this via role alone). + if (userRole === UserRole.ADMIN && !(options?.amr ?? []).includes('pat')) { const clients = await this.clientsRepository.findAll(limit, offset); - const viewer = { userId, userRole, isApiKeyAuth: false }; + const viewer = { userId, userRole, isApiKeyAuth: false, amr: options?.amr }; return Promise.all( clients.map(async (client) => { @@ -206,7 +207,7 @@ export class ClientsService { const accessibleClients = allClients.filter((client) => client.userId === userId || clientIds.has(client.id)); // Apply pagination const paginatedClients = accessibleClients.slice(offset, offset + limit); - const viewer = { userId, userRole, isApiKeyAuth: false }; + const viewer = { userId, userRole, isApiKeyAuth: false, amr: options?.amr }; return Promise.all( paginatedClients.map(async (client) => { @@ -233,7 +234,13 @@ export class ClientsService { * @throws NotFoundException if client is not found * @throws ForbiddenException if user does not have access to the client */ - async findOne(id: string, userId?: string, userRole?: UserRole, isApiKeyAuth = false): Promise { + async findOne( + id: string, + userId?: string, + userRole?: UserRole, + isApiKeyAuth = false, + options?: { amr?: string[] }, + ): Promise { const client = await this.clientsRepository.findByIdOrThrow(id); // Check access permissions @@ -245,6 +252,7 @@ export class ClientsService { userId, userRole, isApiKeyAuth, + options, ); if (!access.hasAccess) { @@ -252,7 +260,7 @@ export class ClientsService { } } - const dto = await this.mapToResponseDto(client, { userId, userRole, isApiKeyAuth }); + const dto = await this.mapToResponseDto(client, { userId, userRole, isApiKeyAuth, amr: options?.amr }); // Fetch config from agent-manager, but don't fail if request fails try { @@ -283,6 +291,7 @@ export class ClientsService { userId?: string, userRole?: UserRole, isApiKeyAuth = false, + options?: { amr?: string[] }, ): Promise { // Check access permissions (workspace managers only) if (!isApiKeyAuth && !this.isApiKeyMode() && userId && userRole) { @@ -293,6 +302,7 @@ export class ClientsService { userId, userRole, isApiKeyAuth, + options, ); } @@ -369,7 +379,7 @@ export class ClientsService { .recordEntityUpdated(StatisticsEntityType.CLIENT, id, {}, userId ?? undefined) .catch(() => undefined); this.notificationPublisher.publishClient('client.updated', updatedClient); - const dto = await this.mapToResponseDto(updatedClient, { userId, userRole, isApiKeyAuth }); + const dto = await this.mapToResponseDto(updatedClient, { userId, userRole, isApiKeyAuth, amr: options?.amr }); // Fetch config from agent-manager, but don't fail if request fails try { @@ -429,12 +439,17 @@ export class ClientsService { * @param isApiKeyAuth - Whether the request is authenticated via API key * @returns Array of client UUIDs */ - async getAccessibleClientIds(userId?: string, userRole?: UserRole, isApiKeyAuth = false): Promise { + async getAccessibleClientIds( + userId?: string, + userRole?: UserRole, + isApiKeyAuth = false, + options?: { amr?: string[] }, + ): Promise { if (isApiKeyAuth || this.isApiKeyMode()) { return await this.clientsRepository.findAllIds(); } - if (userRole === UserRole.ADMIN) { + if (userRole === UserRole.ADMIN && !(options?.amr ?? []).includes('pat')) { return await this.clientsRepository.findAllIds(); } @@ -500,7 +515,7 @@ export class ClientsService { */ private async mapToResponseDto( client: ClientEntity, - viewer: { userId?: string; userRole?: UserRole; isApiKeyAuth: boolean } = { + viewer: { userId?: string; userRole?: UserRole; isApiKeyAuth: boolean; amr?: string[] } = { userId: undefined, userRole: undefined, isApiKeyAuth: false, @@ -526,7 +541,7 @@ export class ClientsService { private async computeCanManageWorkspaceConfiguration( clientId: string, - viewer: { userId?: string; userRole?: UserRole; isApiKeyAuth: boolean }, + viewer: { userId?: string; userRole?: UserRole; isApiKeyAuth: boolean; amr?: string[] }, ): Promise { if (viewer.isApiKeyAuth || this.isApiKeyMode()) { return true; @@ -543,10 +558,11 @@ export class ClientsService { viewer.userId, viewer.userRole, false, + { amr: viewer.amr }, ); return canManageWorkspaceConfiguration( - { userId: viewer.userId, userRole: viewer.userRole, isApiKeyAuth: false }, + { userId: viewer.userId, userRole: viewer.userRole, isApiKeyAuth: false, amr: viewer.amr }, access, ); } diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/tickets.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/tickets.service.ts index b5b41cb0a..570b81f88 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/tickets.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/tickets.service.ts @@ -189,7 +189,9 @@ export class TicketsService { private async getAccessibleClientIds(req?: RequestWithUser): Promise { const info = getUserFromRequest(req || ({} as RequestWithUser)); - return await this.clientsService.getAccessibleClientIds(info.userId, info.userRole, info.isApiKeyAuth); + return await this.clientsService.getAccessibleClientIds(info.userId, info.userRole, info.isApiKeyAuth, { + amr: info.amr, + }); } private async assertClientAccess(clientId: string, req?: RequestWithUser): Promise { diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/container/container.component.html b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/container/container.component.html index c1d667d35..a3a199a4c 100644 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/container/container.component.html +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/container/container.component.html @@ -185,6 +185,18 @@ Audit + @if (isPatUiEnabled) { + + + Tokens + + } @if (canAccessUserManager$ | async) { { + const expectedScopes = [ + 'catalog:write', + 'subscriptions:read', + 'subscriptions:write', + 'invoices:read', + 'invoices:write', + 'invoices:pay', + 'customer_profile:write', + 'customer_profile:admin', + 'usage:read', + 'usage:write', + 'promotions:read', + 'promotions:write', + 'projects:read', + 'projects:write', + 'tickets:read', + 'tickets:write', + 'milestones:write', + 'time_entries:write', + 'billing_admin:read', + 'billing_admin:write', + 'datev:write', + 'users:admin', + 'webhooks:admin', + ]; + + it('has unique entries', () => { + expect(new Set(BILLING_PAT_SCOPES).size).toBe(BILLING_PAT_SCOPES.length); + }); + + it('matches the full billing PAT catalog', () => { + expect(BILLING_PAT_SCOPES).toEqual(expectedScopes); + }); +}); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/auth/billing-pat.scopes.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/auth/billing-pat.scopes.ts new file mode 100644 index 000000000..533a63a42 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/auth/billing-pat.scopes.ts @@ -0,0 +1,32 @@ +import { IDENTITY_PAT_SCOPES } from '@forepath/identity/backend'; + +/** + * Decabill PAT capability scopes (allowlist for create + JWT). + * Every entry must be enforced via `@RequireScopes` (or equivalent) on machine routes. + */ +export const BILLING_PAT_SCOPES = [ + 'catalog:write', + 'subscriptions:read', + 'subscriptions:write', + 'invoices:read', + 'invoices:write', + 'invoices:pay', + 'customer_profile:write', + 'customer_profile:admin', + 'usage:read', + 'usage:write', + 'promotions:read', + 'promotions:write', + 'projects:read', + 'projects:write', + 'tickets:read', + 'tickets:write', + 'milestones:write', + 'time_entries:write', + 'billing_admin:read', + 'billing_admin:write', + 'datev:write', + ...IDENTITY_PAT_SCOPES, +] as const; + +export type BillingPatScope = (typeof BILLING_PAT_SCOPES)[number]; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts index 83a8a15f9..073d5d463 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts @@ -4,6 +4,7 @@ import { RevokedUserTokenEntity, SocketAuthService, UserEntity, + UserPersonalAccessTokenEntity, UsersRepository, RevokedUserTokensRepository, } from '@forepath/identity/backend'; @@ -438,6 +439,7 @@ const DIGITALOCEAN_CONFIG_SCHEMA: Record = { CustomerProfileEntity, UserEntity, RevokedUserTokenEntity, + UserPersonalAccessTokenEntity, DatevExportEntity, DatevDebtorAccountEntity, ProjectEntity, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts index 01853f8cf..75e5a7b68 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts @@ -1,5 +1,5 @@ import { getTenantIdOrDefault } from '@forepath/shared/backend'; -import { KeycloakRoles, UserRole, UsersRoles } from '@forepath/identity/backend'; +import { KeycloakRoles, RequireScopes, UserRole, UsersRoles } from '@forepath/identity/backend'; import { BadRequestException, Body, @@ -73,6 +73,7 @@ export class AdminBillingController { private readonly datevExportConfigService: DatevExportConfigService, ) {} + @RequireScopes('billing_admin:read') @Get('capabilities') getCapabilities(): BillingCapabilitiesResponseDto { const tenantId = getTenantIdOrDefault(); @@ -83,11 +84,13 @@ export class AdminBillingController { }; } + @RequireScopes('billing_admin:read') @Get('summary') async getSummary(): Promise { return await this.billingAdminService.getGlobalSummary(); } + @RequireScopes('billing_admin:write') @Post('bill-now') async billNow(@Body() dto: AdminBillNowDto, @Req() req?: RequestWithUser): Promise { const userInfo = getUserFromRequest(req || ({} as RequestWithUser)); @@ -99,6 +102,7 @@ export class AdminBillingController { return await this.adminBillNowService.queueBillNow(userInfo.userId, dto); } + @RequireScopes('billing_admin:read') @Get('users/:userId/subscriptions') async listUserSubscriptions( @Param('userId', new ParseUUIDPipe({ version: '4' })) userId: string, @@ -110,6 +114,7 @@ export class AdminBillingController { return rows.map((row) => this.mapSubscriptionToResponse(row)); } + @RequireScopes('billing_admin:read') @Get('subscriptions') async listSubscriptions( @Query('limit', new ParseIntPipe({ optional: true })) limit?: number, @@ -125,6 +130,7 @@ export class AdminBillingController { }); } + @RequireScopes('billing_admin:write') @Post('subscriptions/:id/cancel') async cancelSubscription( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @@ -133,6 +139,7 @@ export class AdminBillingController { return await this.billingAdminService.cancelSubscriptionForAdmin(id); } + @RequireScopes('billing_admin:write') @Post('subscriptions/:id/withdraw') async withdrawSubscription( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @@ -141,6 +148,7 @@ export class AdminBillingController { return await this.billingAdminService.withdrawSubscriptionForAdmin(id); } + @RequireScopes('billing_admin:write') @Post('subscriptions/:id/resume') async resumeSubscription( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @@ -149,6 +157,7 @@ export class AdminBillingController { return await this.billingAdminService.resumeSubscriptionForAdmin(id); } + @RequireScopes('billing_admin:read') @Get('invoices') async listInvoices( @Query('limit', new ParseIntPipe({ optional: true })) limit?: number, @@ -165,6 +174,7 @@ export class AdminBillingController { } /** @deprecated Use GET /admin/billing/invoices */ + @RequireScopes('billing_admin:read') @Get('invoices/open-overdue') async listOpenOverdue( @Query('limit', new ParseIntPipe({ optional: true })) limit?: number, @@ -175,6 +185,7 @@ export class AdminBillingController { return await this.listInvoices(limit, offset, search, userId); } + @RequireScopes('billing_admin:write') @Post('invoices/manual') async createManualInvoice( @Body() dto: CreateManualInvoiceDto, @@ -189,6 +200,7 @@ export class AdminBillingController { return await this.manualInvoiceService.createDraft(dto, userInfo.userId); } + @RequireScopes('billing_admin:read') @Get('invoices/:invoiceRefId') async getInvoiceDetail( @Param('invoiceRefId', new ParseUUIDPipe({ version: '4' })) invoiceRefId: string, @@ -196,6 +208,7 @@ export class AdminBillingController { return await this.manualInvoiceService.getDetail(invoiceRefId); } + @RequireScopes('billing_admin:write') @Post('invoices/:invoiceRefId/issue') async issueManualInvoice( @Param('invoiceRefId', new ParseUUIDPipe({ version: '4' })) invoiceRefId: string, @@ -211,6 +224,7 @@ export class AdminBillingController { return await this.manualInvoiceService.issueDraft(invoiceRefId, userInfo.userId, dto); } + @RequireScopes('billing_admin:write') @Post('invoices/:invoiceRefId') async updateManualInvoice( @Param('invoiceRefId', new ParseUUIDPipe({ version: '4' })) invoiceRefId: string, @@ -226,6 +240,7 @@ export class AdminBillingController { return await this.manualInvoiceService.updateDraft(invoiceRefId, dto, userInfo.userId); } + @RequireScopes('billing_admin:write') @Delete('invoices/:invoiceRefId') @HttpCode(HttpStatus.NO_CONTENT) async deleteManualInvoice( @@ -241,6 +256,7 @@ export class AdminBillingController { await this.manualInvoiceService.deleteDraft(invoiceRefId, userInfo.userId); } + @RequireScopes('billing_admin:write') @Post('invoices/:invoiceRefId/void') async voidInvoice( @Param('invoiceRefId', new ParseUUIDPipe({ version: '4' })) invoiceRefId: string, @@ -255,6 +271,7 @@ export class AdminBillingController { return await this.invoiceAdminService.voidInvoice(invoiceRefId, userInfo.userId); } + @RequireScopes('billing_admin:write') @Post('invoices/:invoiceRefId/mark-paid') async markPaid( @Param('invoiceRefId', new ParseUUIDPipe({ version: '4' })) invoiceRefId: string, @@ -270,6 +287,7 @@ export class AdminBillingController { return await this.invoiceAdminService.markPaidManual(invoiceRefId, userInfo.userId, dto); } + @RequireScopes('billing_admin:write') @Post('invoices/:invoiceRefId/mark-unpaid') async markUnpaid( @Param('invoiceRefId', new ParseUUIDPipe({ version: '4' })) invoiceRefId: string, @@ -285,6 +303,7 @@ export class AdminBillingController { return await this.invoiceAdminService.markUnpaidManual(invoiceRefId, userInfo.userId, dto); } + @RequireScopes('billing_admin:read') @Get('invoices/:invoiceRefId/pdf') @Header('Content-Type', 'application/pdf') async downloadInvoicePdf( @@ -305,6 +324,7 @@ export class AdminBillingController { return new StreamableFile(buffer); } + @RequireScopes('billing_admin:read') @Get('invoices/:invoiceRefId/void-document/pdf') @Header('Content-Type', 'application/pdf') async downloadVoidDocumentPdf( @@ -325,6 +345,7 @@ export class AdminBillingController { return new StreamableFile(buffer); } + @RequireScopes('billing_admin:read') @Get('invoices/:invoiceRefId/time-report/pdf') @Header('Content-Type', 'application/pdf') async downloadTimeReportPdf( @@ -345,6 +366,7 @@ export class AdminBillingController { return new StreamableFile(buffer); } + @RequireScopes('billing_admin:read') @Get('invoices/:invoiceRefId/audit-logs') async listAuditLogs( @Param('invoiceRefId', new ParseUUIDPipe({ version: '4' })) invoiceRefId: string, @@ -361,6 +383,7 @@ export class AdminBillingController { }; } + @RequireScopes('billing_admin:read') @Get('statistics/summary') async getStatisticsSummary( @Query('from') from?: string, @@ -378,6 +401,7 @@ export class AdminBillingController { }); } + @RequireScopes('billing_admin:read') @Get('statistics/by-product') async getStatisticsByProduct( @Query('from') from?: string, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-customer-auto-billing.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-customer-auto-billing.controller.ts index 54289eede..edde42b12 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-customer-auto-billing.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-customer-auto-billing.controller.ts @@ -1,4 +1,4 @@ -import { KeycloakRoles, UserRole, UsersRoles } from '@forepath/identity/backend'; +import { KeycloakRoles, RequireScopes, UserRole, UsersRoles } from '@forepath/identity/backend'; import { Controller, HttpCode, HttpStatus, Param, ParseUUIDPipe, Post } from '@nestjs/common'; import type { CustomerProfileResponseDto } from '../dto/customer-profile-response.dto'; @@ -10,6 +10,7 @@ import { PaymentProcessorFactory } from '../payment-processors/payment-processor @Controller('admin/billing/customer-profiles/:id/auto-billing') @KeycloakRoles(UserRole.ADMIN) @UsersRoles(UserRole.ADMIN) +@RequireScopes('customer_profile:admin') export class AdminCustomerAutoBillingController { constructor( private readonly autoBillingService: AutoBillingService, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-customer-profiles.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-customer-profiles.controller.ts index f23e652d5..91681f0e5 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-customer-profiles.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-customer-profiles.controller.ts @@ -1,4 +1,4 @@ -import { KeycloakRoles, UserRole, UsersRoles } from '@forepath/identity/backend'; +import { KeycloakRoles, RequireScopes, UserRole, UsersRoles } from '@forepath/identity/backend'; import { Body, Controller, @@ -24,6 +24,7 @@ import { CustomerProfilesAdminService } from '../services/customer-profiles-admi @Controller('admin/billing/customer-profiles') @KeycloakRoles(UserRole.ADMIN) @UsersRoles(UserRole.ADMIN) +@RequireScopes('customer_profile:admin') export class AdminCustomerProfilesController { constructor(private readonly customerProfilesAdminService: CustomerProfilesAdminService) {} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-datev-exports.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-datev-exports.controller.ts index dbc78371d..b02b34ec7 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-datev-exports.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-datev-exports.controller.ts @@ -1,4 +1,4 @@ -import { KeycloakRoles, UserRole, UsersRoles } from '@forepath/identity/backend'; +import { KeycloakRoles, RequireScopes, UserRole, UsersRoles } from '@forepath/identity/backend'; import { Body, Controller, @@ -27,6 +27,7 @@ import { getUserFromRequest, type RequestWithUser } from '../utils/billing-acces @Controller('admin/billing/datev-exports') @KeycloakRoles(UserRole.ADMIN) @UsersRoles(UserRole.ADMIN) +@RequireScopes('datev:write') @UseGuards(DatevExportEnabledGuard) export class AdminDatevExportsController { constructor(private readonly datevExportAdminService: DatevExportAdminService) {} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-promotions.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-promotions.controller.ts index d43261374..366794d33 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-promotions.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-promotions.controller.ts @@ -1,4 +1,4 @@ -import { KeycloakRoles, UserRole, UsersRoles } from '@forepath/identity/backend'; +import { KeycloakRoles, RequireScopes, UserRole, UsersRoles } from '@forepath/identity/backend'; import { Body, Controller, Delete, Get, Param, ParseIntPipe, ParseUUIDPipe, Post, Put, Query } from '@nestjs/common'; import type { @@ -13,6 +13,7 @@ import { PromotionAdminService } from '../services/promotion-admin.service'; @Controller('admin/billing/promotions') @KeycloakRoles(UserRole.ADMIN) @UsersRoles(UserRole.ADMIN) +@RequireScopes('promotions:write') export class AdminPromotionsController { constructor(private readonly promotionAdminService: PromotionAdminService) {} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-usage.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-usage.controller.ts index 75a7c0d13..2db1d47f1 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-usage.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-usage.controller.ts @@ -1,4 +1,4 @@ -import { KeycloakRoles, UserRole, UsersRoles } from '@forepath/identity/backend'; +import { KeycloakRoles, RequireScopes, UserRole, UsersRoles } from '@forepath/identity/backend'; import { BadRequestException, Body, Controller, Post, Req } from '@nestjs/common'; import { CreateUsageRecordDto } from '../dto/create-usage-record.dto'; @@ -16,6 +16,7 @@ export class AdminUsageController { ) {} @Post('record') + @RequireScopes('usage:write') async record(@Body() body: CreateUsageRecordDto, @Req() req?: RequestWithUser) { const userInfo = getUserFromRequest(req || ({} as RequestWithUser)); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/availability.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/availability.controller.ts index 5bb3dd39f..7cb553dd4 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/availability.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/availability.controller.ts @@ -1,3 +1,4 @@ +import { RequireScopes } from '@forepath/identity/backend'; import { Body, Controller, Post } from '@nestjs/common'; import { AvailabilityCheckDto } from '../dto/availability-check.dto'; @@ -5,6 +6,7 @@ import { AvailabilityResponseDto } from '../dto/availability-response.dto'; import { AvailabilityService } from '../services/availability.service'; @Controller('availability') +@RequireScopes('subscriptions:write') export class AvailabilityController { constructor(private readonly availabilityService: AvailabilityService) {} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/backorders.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/backorders.controller.ts index ee67cacbf..443470e80 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/backorders.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/backorders.controller.ts @@ -1,3 +1,4 @@ +import { RequireScopes } from '@forepath/identity/backend'; import { BadRequestException, Body, @@ -27,6 +28,7 @@ export class BackordersController { private readonly backordersRepository: BackordersRepository, ) {} + @RequireScopes('subscriptions:read') @Get() async list( @Query('limit', new ParseIntPipe({ optional: true })) limit?: number, @@ -44,6 +46,7 @@ export class BackordersController { return await this.backorderService.mapManyToResponses(rows); } + @RequireScopes('subscriptions:write') @Post(':id/retry') async retry( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @@ -67,6 +70,7 @@ export class BackordersController { return await this.backorderService.mapToResponse(row); } + @RequireScopes('subscriptions:write') @Post(':id/cancel') @HttpCode(HttpStatus.OK) async cancel( diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/cloud-init-configs.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/cloud-init-configs.controller.ts index c57f1cbb8..d0e1fb36c 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/cloud-init-configs.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/cloud-init-configs.controller.ts @@ -1,4 +1,4 @@ -import { KeycloakRoles, UserRole, UsersRoles } from '@forepath/identity/backend'; +import { KeycloakRoles, RequireScopes, UserRole, UsersRoles } from '@forepath/identity/backend'; import { Body, Controller, @@ -21,6 +21,7 @@ import { CloudInitConfigsRepository } from '../repositories/cloud-init-configs.r import { CloudInitConfigService } from '../services/cloud-init-config.service'; @Controller('cloud-init-configs') +@RequireScopes('catalog:write') export class CloudInitConfigsController { constructor( private readonly cloudInitConfigsRepository: CloudInitConfigsRepository, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/customer-auto-billing.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/customer-auto-billing.controller.ts index 991995d72..f4153c072 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/customer-auto-billing.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/customer-auto-billing.controller.ts @@ -1,3 +1,4 @@ +import { RequireScopes } from '@forepath/identity/backend'; import { BadRequestException, Controller, HttpCode, HttpStatus, Post, Req } from '@nestjs/common'; import { CustomerProfileResponseDto } from '../dto/customer-profile-response.dto'; @@ -7,6 +8,7 @@ import { PaymentProcessorFactory } from '../payment-processors/payment-processor import { getUserFromRequest, type RequestWithUser } from '../utils/billing-access.utils'; @Controller('customer-profile/auto-billing') +@RequireScopes('customer_profile:write') export class CustomerAutoBillingController { constructor( private readonly autoBillingService: AutoBillingService, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/customer-profiles.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/customer-profiles.controller.ts index 8b261d6e6..4a50c875e 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/customer-profiles.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/customer-profiles.controller.ts @@ -1,3 +1,4 @@ +import { RequireScopes } from '@forepath/identity/backend'; import { BadRequestException, Body, Controller, Get, Post, Req } from '@nestjs/common'; import { CustomerProfileResponseDto } from '../dto/customer-profile-response.dto'; @@ -8,6 +9,7 @@ import { CustomerProfilesService } from '../services/customer-profiles.service'; import { getUserFromRequest, type RequestWithUser } from '../utils/billing-access.utils'; @Controller('customer-profile') +@RequireScopes('customer_profile:write') export class CustomerProfilesController { constructor( private readonly customerProfilesService: CustomerProfilesService, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/invoices.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/invoices.controller.ts index 42335e8a8..e5b15759b 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/invoices.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/invoices.controller.ts @@ -1,4 +1,4 @@ -import { KeycloakRoles, UserRole, UsersRoles } from '@forepath/identity/backend'; +import { KeycloakRoles, RequireScopes, UserRole, UsersRoles } from '@forepath/identity/backend'; import { BadRequestException, Body, @@ -48,6 +48,7 @@ export class InvoicesController { private readonly subscriptionsRepository: SubscriptionsRepository, ) {} + @RequireScopes('invoices:read') @Get('summary') async getSummary(@Req() req?: RequestWithUser): Promise { const userInfo = getUserFromRequest(req || ({} as RequestWithUser)); @@ -71,6 +72,7 @@ export class InvoicesController { }; } + @RequireScopes('invoices:read') @Get('open-overdue') async listOpenOverdue(@Req() req?: RequestWithUser): Promise { const userInfo = getUserFromRequest(req || ({} as RequestWithUser)); @@ -84,6 +86,7 @@ export class InvoicesController { return rows.map((row) => this.invoiceService.mapToResponse(row, row.subscription?.number)); } + @RequireScopes('invoices:read') @Get('history') async listHistory(@Req() req?: RequestWithUser): Promise { const userInfo = getUserFromRequest(req || ({} as RequestWithUser)); @@ -97,6 +100,7 @@ export class InvoicesController { return rows.map((row) => this.invoiceService.mapToResponse(row, row.subscription?.number)); } + @RequireScopes('invoices:read') @Get('ref/:invoiceRefId') async getDetailByRef( @Param('invoiceRefId', new ParseUUIDPipe({ version: '4' })) invoiceRefId: string, @@ -111,6 +115,7 @@ export class InvoicesController { return await this.invoiceService.getDetailForUser(invoiceRefId, userInfo.userId); } + @RequireScopes('invoices:read') @Get('ref/:invoiceRefId/pdf') @Header('Content-Type', 'application/pdf') async downloadPdfByRef( @@ -138,6 +143,7 @@ export class InvoicesController { return new StreamableFile(buffer); } + @RequireScopes('invoices:read') @Get('ref/:invoiceRefId/void-document/pdf') @Header('Content-Type', 'application/pdf') async downloadVoidDocumentPdfByRef( @@ -165,6 +171,7 @@ export class InvoicesController { return new StreamableFile(buffer); } + @RequireScopes('invoices:read') @Get('ref/:invoiceRefId/time-report/pdf') @Header('Content-Type', 'application/pdf') async downloadTimeReportPdfByRef( @@ -192,6 +199,7 @@ export class InvoicesController { return new StreamableFile(buffer); } + @RequireScopes('invoices:pay') @Post('ref/:invoiceRefId/pay') async initiatePaymentByRef( @Param('invoiceRefId', new ParseUUIDPipe({ version: '4' })) invoiceRefId: string, @@ -206,6 +214,7 @@ export class InvoicesController { return await this.paymentOrchestrationService.initiatePaymentForUser(invoiceRefId, userInfo.userId); } + @RequireScopes('invoices:read') @Get(':subscriptionId') async list( @Param('subscriptionId', new ParseUUIDPipe({ version: '4' })) subscriptionId: string, @@ -223,6 +232,7 @@ export class InvoicesController { return rows.map((row) => this.invoiceService.mapToResponse(row)); } + @RequireScopes('invoices:read') @Get(':subscriptionId/ref/:invoiceRefId') async getDetail( @Param('subscriptionId', new ParseUUIDPipe({ version: '4' })) subscriptionId: string, @@ -240,6 +250,7 @@ export class InvoicesController { return await this.invoiceService.getDetail(invoiceRefId, subscriptionId); } + @RequireScopes('invoices:read') @Get(':subscriptionId/ref/:invoiceRefId/pdf') @Header('Content-Type', 'application/pdf') async downloadPdf( @@ -274,6 +285,7 @@ export class InvoicesController { return new StreamableFile(buffer); } + @RequireScopes('invoices:read') @Get(':subscriptionId/ref/:invoiceRefId/void-document/pdf') @Header('Content-Type', 'application/pdf') async downloadVoidDocumentPdf( @@ -308,6 +320,7 @@ export class InvoicesController { return new StreamableFile(buffer); } + @RequireScopes('invoices:read') @Get(':subscriptionId/ref/:invoiceRefId/time-report/pdf') @Header('Content-Type', 'application/pdf') async downloadTimeReportPdf( @@ -342,6 +355,7 @@ export class InvoicesController { return new StreamableFile(buffer); } + @RequireScopes('invoices:write') @Post(':subscriptionId') @KeycloakRoles(UserRole.ADMIN) @UsersRoles(UserRole.ADMIN) @@ -367,6 +381,7 @@ export class InvoicesController { }); } + @RequireScopes('invoices:write') @Post(':subscriptionId/ref/:invoiceRefId/void') @KeycloakRoles(UserRole.ADMIN) @UsersRoles(UserRole.ADMIN) @@ -394,6 +409,7 @@ export class InvoicesController { return this.invoiceService.mapToResponse(voided); } + @RequireScopes('invoices:pay') @Post(':subscriptionId/ref/:invoiceRefId/pay') async initiatePayment( @Param('subscriptionId', new ParseUUIDPipe({ version: '4' })) subscriptionId: string, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/pricing.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/pricing.controller.ts index 691d538c3..18bb461ea 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/pricing.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/pricing.controller.ts @@ -1,3 +1,4 @@ +import { RequireScopes } from '@forepath/identity/backend'; import { Body, Controller, Post } from '@nestjs/common'; import { PricingPreviewDto } from '../dto/pricing-preview.dto'; @@ -12,6 +13,7 @@ import { resolvePlanTaxCategory } from '../utils/plan-tax.utils'; import { resolveServerTypePriceMonthly } from '../utils/server-type-billing.utils'; @Controller('pricing') +@RequireScopes('subscriptions:read') export class PricingController { constructor( private readonly pricingService: PricingService, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/promotions.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/promotions.controller.ts index fdb966ee7..7fbc8b4b2 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/promotions.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/promotions.controller.ts @@ -1,3 +1,4 @@ +import { RequireScopes } from '@forepath/identity/backend'; import { Body, Controller, Get, Param, ParseIntPipe, ParseUUIDPipe, Post, Query, Req } from '@nestjs/common'; import { IsEnum, IsISO8601, IsNotEmpty, IsOptional, IsString, IsUUID, ValidateIf } from 'class-validator'; @@ -46,6 +47,7 @@ class RedeemPromotionBodyDto implements RedeemPromotionDto { } @Controller('promotions') +@RequireScopes('promotions:read') export class PromotionsController { constructor(private readonly promotionRedemptionService: PromotionRedemptionService) {} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-plans.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-plans.controller.ts index 81f4bb78f..7c5cb887e 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-plans.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-plans.controller.ts @@ -1,4 +1,4 @@ -import { KeycloakRoles, UserRole, UsersRoles } from '@forepath/identity/backend'; +import { KeycloakRoles, RequireScopes, UserRole, UsersRoles } from '@forepath/identity/backend'; import { BadRequestException, Body, @@ -43,6 +43,7 @@ export class ServicePlansController { private readonly withdrawalPolicyService: WithdrawalPolicyService, ) {} + @RequireScopes('subscriptions:read') @Get() async list( @Query('limit', new ParseIntPipe({ optional: true })) limit?: number, @@ -58,6 +59,7 @@ export class ServicePlansController { return await Promise.all(rows.map((row) => this.mapToResponse(row))); } + @RequireScopes('subscriptions:read') @Get(':id/order-provisioning-options') async listOrderProvisioningOptions( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @@ -67,6 +69,7 @@ export class ServicePlansController { return this.cloudInitConfigService.buildOrderProvisioningOptions(row.providerConfigDefaults ?? {}); } + @RequireScopes('subscriptions:read') @Get(':id/cloud-init-configs/:configId/order-fields') async getCloudInitOrderFields( @Param('id', new ParseUUIDPipe({ version: '4' })) planId: string, @@ -75,6 +78,7 @@ export class ServicePlansController { return this.cloudInitConfigService.getOrderFieldsForPlan(planId, configId); } + @RequireScopes('subscriptions:read') @Get(':id') async get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string): Promise { const row = await this.servicePlansRepository.findByIdOrThrow(id); @@ -82,6 +86,7 @@ export class ServicePlansController { return await this.mapToResponse(row); } + @RequireScopes('catalog:write') @Post() @KeycloakRoles(UserRole.ADMIN) @UsersRoles(UserRole.ADMIN) @@ -124,6 +129,7 @@ export class ServicePlansController { return await this.mapToResponse(row); } + @RequireScopes('catalog:write') @Post(':id') @KeycloakRoles(UserRole.ADMIN) @UsersRoles(UserRole.ADMIN) @@ -197,6 +203,7 @@ export class ServicePlansController { return await this.mapToResponse(row); } + @RequireScopes('catalog:write') @Delete(':id') @KeycloakRoles(UserRole.ADMIN) @UsersRoles(UserRole.ADMIN) diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-types.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-types.controller.ts index 732ddca95..512c8f06c 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-types.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-types.controller.ts @@ -1,4 +1,4 @@ -import { KeycloakRoles, UserRole, UsersRoles } from '@forepath/identity/backend'; +import { KeycloakRoles, RequireScopes, UserRole, UsersRoles } from '@forepath/identity/backend'; import { Body, Controller, @@ -45,6 +45,7 @@ export class ServiceTypesController { * Get server types with specs and pricing for a provider (e.g. hetzner). * Used by the billing console to show server type dropdown with price and to auto-set base price. */ + @RequireScopes('subscriptions:read') @Get('providers/:providerId/server-types') async getProviderServerTypes( @Param('providerId') providerId: string, @@ -69,6 +70,7 @@ export class ServiceTypesController { * Get geography options (locations/regions) with human-readable labels for a provider. * Used by the billing console for location/region enum dropdowns. */ + @RequireScopes('subscriptions:read') @Get('providers/:providerId/locations') async getProviderLocations( @Param('providerId') providerId: string, @@ -93,11 +95,13 @@ export class ServiceTypesController { * Get all registered provider details (id, displayName, configSchema). * Used by clients to build provider selectors and validate provider-specific config. */ + @RequireScopes('subscriptions:read') @Get('providers') async getProviders(): Promise { return this.providerRegistry.getProviders(); } + @RequireScopes('subscriptions:read') @Get() async list( @Query('limit', new ParseIntPipe({ optional: true })) limit?: number, @@ -108,6 +112,7 @@ export class ServiceTypesController { return rows.map((row) => this.mapToResponse(row)); } + @RequireScopes('subscriptions:read') @Get(':id') async get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string): Promise { const row = await this.serviceTypesRepository.findByIdOrThrow(id); @@ -115,6 +120,7 @@ export class ServiceTypesController { return this.mapToResponse(row); } + @RequireScopes('catalog:write') @Post() @KeycloakRoles(UserRole.ADMIN) @UsersRoles(UserRole.ADMIN) @@ -134,6 +140,7 @@ export class ServiceTypesController { return this.mapToResponse(row); } + @RequireScopes('catalog:write') @Post(':id') @KeycloakRoles(UserRole.ADMIN) @UsersRoles(UserRole.ADMIN) @@ -162,6 +169,7 @@ export class ServiceTypesController { return this.mapToResponse(row); } + @RequireScopes('catalog:write') @Delete(':id') @KeycloakRoles(UserRole.ADMIN) @UsersRoles(UserRole.ADMIN) diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/subscription-items.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/subscription-items.controller.ts index 8eb37084c..0913a3cfe 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/subscription-items.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/subscription-items.controller.ts @@ -1,3 +1,4 @@ +import { RequireScopes } from '@forepath/identity/backend'; import { BadRequestException, Controller, Get, Param, ParseUUIDPipe, Post, Req } from '@nestjs/common'; import { ServerInfoResponseDto } from '../dto/server-info-response.dto'; @@ -8,6 +9,7 @@ import { getUserFromRequest, type RequestWithUser } from '../utils/billing-acces export class SubscriptionItemsController { constructor(private readonly subscriptionItemServerService: SubscriptionItemServerService) {} + @RequireScopes('subscriptions:read') @Get() async listItems( @Param('subscriptionId', new ParseUUIDPipe({ version: '4' })) subscriptionId: string, @@ -22,6 +24,7 @@ export class SubscriptionItemsController { return await this.subscriptionItemServerService.listItems(subscriptionId, userInfo.userId); } + @RequireScopes('subscriptions:read') @Get(':itemId/server-info') async getServerInfo( @Param('subscriptionId', new ParseUUIDPipe({ version: '4' })) subscriptionId: string, @@ -39,6 +42,7 @@ export class SubscriptionItemsController { return toServerInfoResponse(info); } + @RequireScopes('subscriptions:write') @Post(':itemId/actions/start') async startServer( @Param('subscriptionId', new ParseUUIDPipe({ version: '4' })) subscriptionId: string, @@ -56,6 +60,7 @@ export class SubscriptionItemsController { return { success: true }; } + @RequireScopes('subscriptions:write') @Post(':itemId/actions/stop') async stopServer( @Param('subscriptionId', new ParseUUIDPipe({ version: '4' })) subscriptionId: string, @@ -73,6 +78,7 @@ export class SubscriptionItemsController { return { success: true }; } + @RequireScopes('subscriptions:write') @Post(':itemId/actions/restart') async restartServer( @Param('subscriptionId', new ParseUUIDPipe({ version: '4' })) subscriptionId: string, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/subscriptions.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/subscriptions.controller.ts index 48a06d7c3..efc7f452d 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/subscriptions.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/subscriptions.controller.ts @@ -1,3 +1,4 @@ +import { RequireScopes } from '@forepath/identity/backend'; import { BadRequestException, Body, @@ -23,6 +24,7 @@ import { getUserFromRequest, type RequestWithUser } from '../utils/billing-acces export class SubscriptionsController { constructor(private readonly subscriptionService: SubscriptionService) {} + @RequireScopes('subscriptions:write') @Post() async create(@Body() dto: CreateSubscriptionDto, @Req() req?: RequestWithUser): Promise { const userInfo = getUserFromRequest(req || ({} as RequestWithUser)); @@ -43,6 +45,7 @@ export class SubscriptionsController { return (await this.subscriptionService.mapManyToResponses([subscription]))[0]; } + @RequireScopes('subscriptions:read') @Get() async list( @Query('limit', new ParseIntPipe({ optional: true })) limit?: number, @@ -60,6 +63,7 @@ export class SubscriptionsController { return await this.subscriptionService.mapManyToResponses(rows); } + @RequireScopes('subscriptions:read') @Get(':id') async get( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @@ -76,6 +80,7 @@ export class SubscriptionsController { return (await this.subscriptionService.mapManyToResponses([row]))[0]; } + @RequireScopes('subscriptions:write') @Post(':id/cancel') async cancel( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @@ -93,6 +98,7 @@ export class SubscriptionsController { return (await this.subscriptionService.mapManyToResponses([row]))[0]; } + @RequireScopes('subscriptions:write') @Post(':id/withdraw') async withdraw( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @@ -112,6 +118,7 @@ export class SubscriptionsController { return { ...mapped, withdrawalResult }; } + @RequireScopes('subscriptions:write') @Post(':id/resume') async resume( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/usage.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/usage.controller.ts index 208af89ed..e62c8f63c 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/usage.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/usage.controller.ts @@ -1,3 +1,4 @@ +import { RequireScopes } from '@forepath/identity/backend'; import { BadRequestException, Controller, Get, Param, ParseUUIDPipe, Req } from '@nestjs/common'; import { UsageSummaryDto } from '../dto/usage-summary.dto'; @@ -12,6 +13,7 @@ export class UsageController { private readonly subscriptionService: SubscriptionService, ) {} + @RequireScopes('usage:read') @Get('summary/:subscriptionId') async summary( @Param('subscriptionId', new ParseUUIDPipe({ version: '4' })) subscriptionId: string, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/modules/billing-identity-email-bridge.module.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/modules/billing-identity-email-bridge.module.ts index a82cdd0e8..d1a204717 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/modules/billing-identity-email-bridge.module.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/modules/billing-identity-email-bridge.module.ts @@ -1,4 +1,4 @@ -import { IDENTITY_EMAIL_DISPATCHER, type IdentityEmailPublishInput } from '@forepath/identity/backend'; +import { IDENTITY_EMAIL_DISPATCHER, assertPatScopes, type IdentityEmailPublishInput } from '@forepath/identity/backend'; import { EmailNotificationDispatcherService, getTenantIdOrDefault, @@ -24,7 +24,12 @@ export const billingNotificationsModule = NotificationsModule.register({ queueName: BILLING_QUEUE_NAME, eventCatalog: BILLING_NOTIFICATION_EVENTS, resolveScopeKey: () => getTenantIdOrDefault(), - assertAdmin: (req) => ensureAdmin(getUserFromRequest(req as RequestWithUser)), + assertAdmin: (req) => { + const userInfo = getUserFromRequest(req as RequestWithUser); + + ensureAdmin(userInfo); + assertPatScopes(userInfo, 'webhooks:admin'); + }, email: { templateRoots: [...resolveBillingEmailTemplateRoots(), ...resolveIdentityEmailTemplateRoots()], emailEventCatalog: [...BILLING_EMAIL_EVENTS, ...IDENTITY_EMAIL_EVENTS], diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/modules/billing-pat-auth.module.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/modules/billing-pat-auth.module.ts new file mode 100644 index 000000000..93a7ddfc7 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/modules/billing-pat-auth.module.ts @@ -0,0 +1,10 @@ +import { PatAuthModule } from '@forepath/identity/backend'; + +import { BILLING_PAT_SCOPES } from '../auth/billing-pat.scopes'; + +/** + * PAT auth for billing-manager (users-mode and keycloak-mode) with Decabill scope catalog. + */ +export const BillingPatAuthModule = PatAuthModule.register({ + patScopeCatalog: BILLING_PAT_SCOPES, +}); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/modules/billing-users-auth.module.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/modules/billing-users-auth.module.ts new file mode 100644 index 000000000..5f0093613 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/modules/billing-users-auth.module.ts @@ -0,0 +1,10 @@ +import { UsersAuthModule } from '@forepath/identity/backend'; + +import { BILLING_PAT_SCOPES } from '../auth/billing-pat.scopes'; + +/** + * Users-mode auth for billing-manager with Decabill PAT scope catalog. + */ +export const BillingUsersAuthModule = UsersAuthModule.register({ + patScopeCatalog: BILLING_PAT_SCOPES, +}); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/admin-projects.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/admin-projects.controller.ts index a8a61a9be..21243da85 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/admin-projects.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/admin-projects.controller.ts @@ -1,4 +1,4 @@ -import { KeycloakRoles, UserRole, UsersRoles } from '@forepath/identity/backend'; +import { KeycloakRoles, RequireScopes, UserRole, UsersRoles } from '@forepath/identity/backend'; import { BadRequestException, Body, @@ -41,6 +41,7 @@ import { ProjectsAdminService } from '../services/projects-admin.service'; export class AdminProjectsController { constructor(private readonly projectsAdminService: ProjectsAdminService) {} + @RequireScopes('billing_admin:read') @Get() async list( @Query('limit', new ParseIntPipe({ optional: true })) limit?: number, @@ -51,21 +52,25 @@ export class AdminProjectsController { return await this.projectsAdminService.list(limit ?? 10, offset ?? 0, { search, userId }); } + @RequireScopes('billing_admin:read') @Get(':id/summary') async summary(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string): Promise { return await this.projectsAdminService.getSummary(id); } + @RequireScopes('billing_admin:read') @Get(':id') async get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { return await this.projectsAdminService.getById(id); } + @RequireScopes('billing_admin:write') @Post() async create(@Body() dto: CreateAdminProjectDto): Promise { return await this.projectsAdminService.create(dto); } + @RequireScopes('billing_admin:write') @Post(':id') async update( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @@ -74,6 +79,7 @@ export class AdminProjectsController { return await this.projectsAdminService.update(id, dto); } + @RequireScopes('billing_admin:read') @Get(':id/unbilled-time-bounds') async unbilledTimeBounds( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @@ -81,6 +87,7 @@ export class AdminProjectsController { return await this.projectsAdminService.getUnbilledTimeBounds(id); } + @RequireScopes('billing_admin:read') @Post(':id/time-report') @Header('Content-Type', 'application/pdf') async generateTimeReport( @@ -95,6 +102,7 @@ export class AdminProjectsController { return new StreamableFile(buffer); } + @RequireScopes('billing_admin:write') @Post(':id/bill-time') async billTime( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @@ -110,6 +118,7 @@ export class AdminProjectsController { return await this.projectsAdminService.billTime(id, userInfo.userId, dto); } + @RequireScopes('billing_admin:write') @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) async delete(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string): Promise { diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/project-milestones.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/project-milestones.controller.ts index 11e7a46c0..90ec1d674 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/project-milestones.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/project-milestones.controller.ts @@ -1,3 +1,4 @@ +import { RequireScopes } from '@forepath/identity/backend'; import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Post, Req } from '@nestjs/common'; import type { RequestWithUser } from '../../utils/billing-access.utils'; @@ -13,6 +14,7 @@ import { ProjectMilestonesService } from '../services/project-milestones.service export class ProjectMilestonesController { constructor(private readonly milestonesService: ProjectMilestonesService) {} + @RequireScopes('projects:read') @Get() async list( @Param('projectId', new ParseUUIDPipe({ version: '4' })) projectId: string, @@ -21,6 +23,7 @@ export class ProjectMilestonesController { return await this.milestonesService.list(projectId, getAuthenticatedUserFromRequest(req)); } + @RequireScopes('milestones:write') @Post() async create( @Param('projectId', new ParseUUIDPipe({ version: '4' })) projectId: string, @@ -30,6 +33,7 @@ export class ProjectMilestonesController { return await this.milestonesService.create(projectId, dto, getUserFromRequest(req)); } + @RequireScopes('milestones:write') @Post(':id') async update( @Param('projectId', new ParseUUIDPipe({ version: '4' })) projectId: string, @@ -40,6 +44,7 @@ export class ProjectMilestonesController { return await this.milestonesService.update(projectId, id, dto, getUserFromRequest(req)); } + @RequireScopes('milestones:write') @Post(':id/lock') async lock( @Param('projectId', new ParseUUIDPipe({ version: '4' })) projectId: string, @@ -49,6 +54,7 @@ export class ProjectMilestonesController { return await this.milestonesService.lock(projectId, id, getUserFromRequest(req)); } + @RequireScopes('milestones:write') @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) async delete( diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/project-tickets.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/project-tickets.controller.ts index a5afce156..1d0a282ad 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/project-tickets.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/project-tickets.controller.ts @@ -1,3 +1,4 @@ +import { RequireScopes } from '@forepath/identity/backend'; import { Body, Controller, @@ -33,6 +34,7 @@ const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3} export class ProjectTicketsController { constructor(private readonly ticketsService: ProjectTicketsService) {} + @RequireScopes('tickets:read') @Get() async list( @Param('projectId', new ParseUUIDPipe({ version: '4' })) projectId: string, @@ -58,6 +60,7 @@ export class ProjectTicketsController { }); } + @RequireScopes('tickets:read') @Get(':id/activity') async activity( @Param('projectId', new ParseUUIDPipe({ version: '4' })) projectId: string, @@ -75,6 +78,7 @@ export class ProjectTicketsController { ); } + @RequireScopes('tickets:read') @Get(':id/comments') async comments( @Param('projectId', new ParseUUIDPipe({ version: '4' })) projectId: string, @@ -84,6 +88,7 @@ export class ProjectTicketsController { return await this.ticketsService.listComments(projectId, id, getAuthenticatedUserFromRequest(req)); } + @RequireScopes('tickets:read') @Get(':id') async get( @Param('projectId', new ParseUUIDPipe({ version: '4' })) projectId: string, @@ -99,6 +104,7 @@ export class ProjectTicketsController { ); } + @RequireScopes('tickets:write') @Post() async create( @Param('projectId', new ParseUUIDPipe({ version: '4' })) projectId: string, @@ -108,6 +114,7 @@ export class ProjectTicketsController { return await this.ticketsService.create(projectId, dto, req); } + @RequireScopes('tickets:write') @Post(':id/comments') async addComment( @Param('projectId', new ParseUUIDPipe({ version: '4' })) projectId: string, @@ -118,6 +125,7 @@ export class ProjectTicketsController { return await this.ticketsService.addComment(projectId, id, dto, req); } + @RequireScopes('tickets:write') @Post(':id') async update( @Param('projectId', new ParseUUIDPipe({ version: '4' })) projectId: string, @@ -128,6 +136,7 @@ export class ProjectTicketsController { return await this.ticketsService.update(projectId, id, dto, req); } + @RequireScopes('tickets:write') @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) async delete( diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/project-time-entries.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/project-time-entries.controller.ts index e8f4cd0cf..00db08f07 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/project-time-entries.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/project-time-entries.controller.ts @@ -1,3 +1,4 @@ +import { RequireScopes } from '@forepath/identity/backend'; import { Body, Controller, @@ -27,6 +28,7 @@ import { ProjectTimeEntriesService } from '../services/project-time-entries.serv export class ProjectTimeEntriesController { constructor(private readonly timeEntriesService: ProjectTimeEntriesService) {} + @RequireScopes('projects:read') @Get() async list( @Param('projectId', new ParseUUIDPipe({ version: '4' })) projectId: string, @@ -44,6 +46,7 @@ export class ProjectTimeEntriesController { ); } + @RequireScopes('time_entries:write') @Post() async create( @Param('projectId', new ParseUUIDPipe({ version: '4' })) projectId: string, @@ -53,6 +56,7 @@ export class ProjectTimeEntriesController { return await this.timeEntriesService.create(projectId, dto, req); } + @RequireScopes('time_entries:write') @Post(':id') async update( @Param('projectId', new ParseUUIDPipe({ version: '4' })) projectId: string, @@ -63,6 +67,7 @@ export class ProjectTimeEntriesController { return await this.timeEntriesService.update(projectId, id, dto, req); } + @RequireScopes('time_entries:write') @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) async delete( diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/projects.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/projects.controller.ts index 8aec7323d..3eb8fe710 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/projects.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/controllers/projects.controller.ts @@ -1,3 +1,4 @@ +import { RequireScopes } from '@forepath/identity/backend'; import { BadRequestException, Controller, Get, Param, ParseIntPipe, ParseUUIDPipe, Query, Req } from '@nestjs/common'; import type { PaginatedProjectsResponseDto, ProjectResponseDto, ProjectSummaryResponseDto } from '../dto/project.dto'; @@ -5,6 +6,7 @@ import { ProjectsService } from '../services/projects.service'; import { getUserFromRequest, type RequestWithUser } from '../../utils/billing-access.utils'; @Controller('projects') +@RequireScopes('projects:read') export class ProjectsController { constructor(private readonly projectsService: ProjectsService) {} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/utils/project-access.utils.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/utils/project-access.utils.ts index 7c59b658d..8a87a5b2f 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/utils/project-access.utils.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/utils/project-access.utils.ts @@ -12,7 +12,8 @@ export function ensureProjectReadable(userInfo: UserInfoFromRequest, project: Pr throw new ForbiddenException('User not authenticated'); } - if (userInfo.userRole === UserRole.ADMIN) { + // PAT sessions must not inherit console admin cross-project access from the role claim alone. + if (userInfo.userRole === UserRole.ADMIN && !(userInfo.amr ?? []).includes('pat')) { return; } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/billing-access.utils.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/billing-access.utils.spec.ts index c0af68c71..3e727cbca 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/billing-access.utils.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/billing-access.utils.spec.ts @@ -30,6 +30,8 @@ describe('billing-access.utils', () => { userId: 'admin-1', userRole: UserRole.ADMIN, isApiKeyAuth: false, + amr: undefined, + scopes: undefined, }); }); @@ -54,4 +56,34 @@ describe('billing-access.utils', () => { ForbiddenException, ); }); + + it('ensureAdmin allows password admin sessions', () => { + expect(() => + ensureAdmin({ userId: 'u1', userRole: UserRole.ADMIN, isApiKeyAuth: false, amr: ['pwd'] }), + ).not.toThrow(); + }); + + it('ensureAdmin allows PAT admin when role is admin (scopes enforced by PatScopesGuard)', () => { + expect(() => + ensureAdmin({ + userId: 'u1', + userRole: UserRole.ADMIN, + isApiKeyAuth: false, + amr: ['pat'], + scopes: ['usage:write'], + }), + ).not.toThrow(); + }); + + it('ensureAdmin rejects PAT non-admin even with scopes', () => { + expect(() => + ensureAdmin({ + userId: 'u1', + userRole: UserRole.USER, + isApiKeyAuth: false, + amr: ['pat'], + scopes: ['usage:write'], + }), + ).toThrow(ForbiddenException); + }); }); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/billing-access.utils.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/billing-access.utils.ts index c3487224a..15c41ed4a 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/billing-access.utils.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/billing-access.utils.ts @@ -3,7 +3,14 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { Request } from 'express'; export interface RequestWithUser extends Request { - user?: { id: string; email?: string; roles?: string[]; username?: string }; + user?: { + id: string; + email?: string; + roles?: string[]; + username?: string; + amr?: string[]; + scopes?: string[]; + }; apiKeyAuthenticated?: boolean; } @@ -11,6 +18,8 @@ export interface UserInfoFromRequest { userId?: string; userRole?: UserRole; isApiKeyAuth: boolean; + amr?: string[]; + scopes?: string[]; } export function getUserFromRequest(req: RequestWithUser): UserInfoFromRequest { @@ -36,6 +45,8 @@ export function getUserFromRequest(req: RequestWithUser): UserInfoFromRequest { userId: user.id, userRole, isApiKeyAuth: false, + amr: user.amr, + scopes: user.scopes, }; } diff --git a/libs/domains/decabill/frontend/feature-billing-console/src/lib/container/container.component.html b/libs/domains/decabill/frontend/feature-billing-console/src/lib/container/container.component.html index 48ca86f5f..7b3177dcc 100644 --- a/libs/domains/decabill/frontend/feature-billing-console/src/lib/container/container.component.html +++ b/libs/domains/decabill/frontend/feature-billing-console/src/lib/container/container.component.html @@ -182,6 +182,18 @@ Projects + @if (isPatUiEnabled) { + + + Tokens + + } @if (canAccessAdministration$ | async) { ({ scope })); + } + + @RequireInteractiveSession() + @Get('auth/tokens') + async listOwn(@Req() req: RequestWithUser) { + return (await this.patService.listForUser(req.user!.id)).map((item) => this.toResponse(item)); + } + + @RequireInteractiveSession() + @Post('auth/tokens') + async create(@Body() dto: CreatePersonalAccessTokenDto, @Req() req: RequestWithUser) { + const created = await this.patService.create(req.user!.id, { + name: dto.name, + scopes: dto.scopes, + expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null, + }); + + return this.toResponse(created, created.token); + } + + @RequireInteractiveSession() + @Patch('auth/tokens/:id') + async update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdatePersonalAccessTokenDto, + @Req() req: RequestWithUser, + ) { + const updated = await this.patService.update(req.user!.id, id, { + name: dto.name, + scopes: dto.scopes, + }); + + return this.toResponse(updated); + } + + @RequireInteractiveSession() + @Delete('auth/tokens/:id') + @HttpCode(HttpStatus.NO_CONTENT) + async revokeOwn(@Param('id', ParseUUIDPipe) id: string, @Req() req: RequestWithUser) { + await this.patService.revoke(req.user!.id, id); + } + + @RequireInteractiveSession() + @KeycloakRoles(UserRole.ADMIN) + @UsersRoles(UserRole.ADMIN) + @Get('users/:userId/tokens') + async listForUser(@Param('userId', ParseUUIDPipe) userId: string) { + return (await this.patService.listForUserAdmin(userId)).map((item) => this.toResponse(item)); + } + + @RequireInteractiveSession() + @KeycloakRoles(UserRole.ADMIN) + @UsersRoles(UserRole.ADMIN) + @Delete('users/:userId/tokens/:tokenId') + @HttpCode(HttpStatus.NO_CONTENT) + async revokeForUser( + @Param('userId', ParseUUIDPipe) userId: string, + @Param('tokenId', ParseUUIDPipe) tokenId: string, + ) { + await this.patService.revokeAsAdmin(userId, tokenId); + } + + private toResponse( + item: { + id: string; + name: string; + tokenPrefix: string; + scopes: string[]; + expiresAt: Date | null; + revokedAt: Date | null; + lastUsedAt: Date | null; + createdAt: Date; + }, + token?: string, + ) { + return { + id: item.id, + name: item.name, + tokenPrefix: item.tokenPrefix, + scopes: item.scopes, + expiresAt: item.expiresAt?.toISOString() ?? null, + revokedAt: item.revokedAt?.toISOString() ?? null, + lastUsedAt: item.lastUsedAt?.toISOString() ?? null, + createdAt: item.createdAt.toISOString(), + ...(token ? { token } : {}), + }; + } +} diff --git a/libs/domains/identity/backend/feature-auth/src/lib/controllers/users.controller.ts b/libs/domains/identity/backend/feature-auth/src/lib/controllers/users.controller.ts index 0f4486b7b..2a2d07622 100644 --- a/libs/domains/identity/backend/feature-auth/src/lib/controllers/users.controller.ts +++ b/libs/domains/identity/backend/feature-auth/src/lib/controllers/users.controller.ts @@ -16,6 +16,7 @@ import { } from '@nestjs/common'; import { Request } from 'express'; +import { RequireScopes } from '../decorators/require-scopes.decorator'; import { CreateUserDto } from '../dto/auth/create-user.dto'; import { UpdateUserDto } from '../dto/auth/update-user.dto'; import { UsersAuthGuard } from '../guards/users-auth.guard'; @@ -25,6 +26,7 @@ import { UsersService } from '../services/users.service'; @UseGuards(UsersAuthGuard) @KeycloakRoles(UserRole.ADMIN) @UsersRoles(UserRole.ADMIN) +@RequireScopes('users:admin') export class UsersController { constructor(private readonly usersService: UsersService) {} diff --git a/libs/domains/identity/backend/feature-auth/src/lib/decorators/require-scopes.decorator.ts b/libs/domains/identity/backend/feature-auth/src/lib/decorators/require-scopes.decorator.ts new file mode 100644 index 000000000..e229aab35 --- /dev/null +++ b/libs/domains/identity/backend/feature-auth/src/lib/decorators/require-scopes.decorator.ts @@ -0,0 +1,22 @@ +import { SetMetadata } from '@nestjs/common'; + +import { REQUIRE_PASSWORD_SESSION_KEY, REQUIRE_SCOPES_KEY } from '../constants/pat.constants'; + +/** + * Require the listed PAT scopes when the JWT was issued via personal access token. + * Interactive console sessions (`amr` not including `pat`) always pass. + */ +export const RequireScopes = (...scopes: string[]) => SetMetadata(REQUIRE_SCOPES_KEY, scopes); + +/** + * Restrict the route to interactive console sessions (password JWT or Keycloak OIDC). + * Rejects machine JWTs with `amr: pat`. Used for PAT CRUD so tokens cannot mint or revoke tokens. + * + * Keycloak interactive tokens typically have no `amr` claim; PatScopesGuard treats that as interactive. + */ +export const RequireInteractiveSession = () => SetMetadata(REQUIRE_PASSWORD_SESSION_KEY, true); + +/** + * @deprecated Prefer {@link RequireInteractiveSession}. Same metadata key — kept for existing call sites. + */ +export const RequirePasswordSession = RequireInteractiveSession; diff --git a/libs/domains/identity/backend/feature-auth/src/lib/dto/auth/personal-access-token.dto.ts b/libs/domains/identity/backend/feature-auth/src/lib/dto/auth/personal-access-token.dto.ts new file mode 100644 index 000000000..57e3b09e9 --- /dev/null +++ b/libs/domains/identity/backend/feature-auth/src/lib/dto/auth/personal-access-token.dto.ts @@ -0,0 +1,63 @@ +import { + ArrayMinSize, + ArrayUnique, + IsArray, + IsDateString, + IsOptional, + IsString, + MaxLength, + MinLength, +} from 'class-validator'; + +export class CreatePersonalAccessTokenDto { + @IsString() + @MinLength(1) + @MaxLength(255) + name!: string; + + @IsArray() + @ArrayMinSize(1) + @ArrayUnique() + @IsString({ each: true }) + scopes!: string[]; + + @IsOptional() + @IsDateString() + expiresAt?: string; +} + +export class UpdatePersonalAccessTokenDto { + @IsString() + @MinLength(1) + @MaxLength(255) + name!: string; + + @IsArray() + @ArrayMinSize(1) + @ArrayUnique() + @IsString({ each: true }) + scopes!: string[]; +} + +export class ExchangePersonalAccessTokenDto { + @IsString() + @MinLength(1) + token!: string; +} + +export class PersonalAccessTokenResponseDto { + id!: string; + name!: string; + tokenPrefix!: string; + scopes!: string[]; + expiresAt!: string | null; + revokedAt!: string | null; + lastUsedAt!: string | null; + createdAt!: string; + /** Present only on create. */ + token?: string; +} + +export class PersonalAccessTokenScopeDto { + scope!: string; +} diff --git a/libs/domains/identity/backend/feature-auth/src/lib/guards/keycloak-auth.guard.ts b/libs/domains/identity/backend/feature-auth/src/lib/guards/keycloak-auth.guard.ts index 39823efb8..8798cdd1b 100644 --- a/libs/domains/identity/backend/feature-auth/src/lib/guards/keycloak-auth.guard.ts +++ b/libs/domains/identity/backend/feature-auth/src/lib/guards/keycloak-auth.guard.ts @@ -42,7 +42,15 @@ export class KeycloakAuthGuard implements CanActivate { return true; } - const request = context.switchToHttp().getRequest(); + const request = context.switchToHttp().getRequest<{ + patAuthenticated?: boolean; + user?: KeycloakTokenPayload | { id: string; username: string; roles: UserRole[] }; + }>(); + + if (request.patAuthenticated) { + return true; + } + const tokenPayload = request.user as KeycloakTokenPayload | undefined; if (!tokenPayload?.sub) { diff --git a/libs/domains/identity/backend/feature-auth/src/lib/guards/keycloak-roles.guard.ts b/libs/domains/identity/backend/feature-auth/src/lib/guards/keycloak-roles.guard.ts index 0aea11617..c0fa2c864 100644 --- a/libs/domains/identity/backend/feature-auth/src/lib/guards/keycloak-roles.guard.ts +++ b/libs/domains/identity/backend/feature-auth/src/lib/guards/keycloak-roles.guard.ts @@ -27,6 +27,12 @@ export class KeycloakRolesGuard implements CanActivate { return true; } + const request = context.switchToHttp().getRequest<{ patAuthenticated?: boolean; user?: unknown }>(); + + if (request.patAuthenticated) { + return true; + } + const requiredRoles = this.reflector.getAllAndOverride(KEYCLOAK_ROLES_KEY, [ context.getHandler(), context.getClass(), @@ -36,7 +42,7 @@ export class KeycloakRolesGuard implements CanActivate { return true; } - const { user } = context.switchToHttp().getRequest(); + const { user } = request; const roles = this.getRolesFromKeycloakToken(user); if (roles.length === 0) { diff --git a/libs/domains/identity/backend/feature-auth/src/lib/guards/pat-bearer-auth.guard.spec.ts b/libs/domains/identity/backend/feature-auth/src/lib/guards/pat-bearer-auth.guard.spec.ts new file mode 100644 index 000000000..575b11a2d --- /dev/null +++ b/libs/domains/identity/backend/feature-auth/src/lib/guards/pat-bearer-auth.guard.spec.ts @@ -0,0 +1,129 @@ +import { UserRole } from '@forepath/identity/backend'; +import { UnauthorizedException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { JwtService } from '@nestjs/jwt'; + +import { PatBearerAuthGuard } from './pat-bearer-auth.guard'; + +describe('PatBearerAuthGuard', () => { + let guard: PatBearerAuthGuard; + let jwtService: { verifyAsync: jest.Mock }; + let usersRepository: { findById: jest.Mock }; + let revokedUserTokensRepository: { isRevoked: jest.Mock }; + let personalAccessTokenService: { assertPatJwtActive: jest.Mock }; + let originalAuthMethod: string | undefined; + const createContext = (request: Record) => + ({ + switchToHttp: () => ({ + getRequest: () => request, + }), + getHandler: () => ({}), + getClass: () => ({}), + }) as never; + + beforeEach(() => { + originalAuthMethod = process.env.AUTHENTICATION_METHOD; + process.env.AUTHENTICATION_METHOD = 'keycloak'; + jwtService = { verifyAsync: jest.fn() }; + usersRepository = { findById: jest.fn() }; + revokedUserTokensRepository = { isRevoked: jest.fn().mockResolvedValue(false) }; + personalAccessTokenService = { assertPatJwtActive: jest.fn() }; + guard = new PatBearerAuthGuard( + jwtService as unknown as JwtService, + { getAllAndOverride: jest.fn().mockReturnValue(false) } as unknown as Reflector, + usersRepository as never, + revokedUserTokensRepository as never, + personalAccessTokenService as never, + ); + }); + + afterEach(() => { + if (originalAuthMethod !== undefined) { + process.env.AUTHENTICATION_METHOD = originalAuthMethod; + } else { + delete process.env.AUTHENTICATION_METHOD; + } + }); + + it('no-ops when AUTHENTICATION_METHOD is not keycloak', async () => { + process.env.AUTHENTICATION_METHOD = 'users'; + const request = { headers: { authorization: 'Bearer x' } }; + + await expect(guard.canActivate(createContext(request))).resolves.toBe(true); + expect(jwtService.verifyAsync).not.toHaveBeenCalled(); + }); + + it('passes through when bearer is not an app JWT', async () => { + jwtService.verifyAsync.mockRejectedValue(new Error('invalid')); + const request = { headers: { authorization: 'Bearer keycloak-access-token' } }; + + await expect(guard.canActivate(createContext(request))).resolves.toBe(true); + expect(request).not.toHaveProperty('patAuthenticated', true); + }); + + it('passes through when app JWT is not a PAT session', async () => { + jwtService.verifyAsync.mockResolvedValue({ sub: 'u1', amr: ['pwd'] }); + const request = { headers: { authorization: 'Bearer pwd-jwt' } }; + + await expect(guard.canActivate(createContext(request))).resolves.toBe(true); + expect(request).not.toHaveProperty('patAuthenticated', true); + }); + + it('authenticates PAT JWTs and sets patAuthenticated', async () => { + jwtService.verifyAsync.mockResolvedValue({ + sub: 'u1', + amr: ['pat'], + patId: 't1', + scopes: ['usage:write'], + jti: 'j1', + exp: 9999999999, + tv: 0, + }); + usersRepository.findById.mockResolvedValue({ + id: 'u1', + email: 'a@b.c', + role: UserRole.ADMIN, + lockedAt: null, + tenantId: 'default', + tokenVersion: 0, + }); + personalAccessTokenService.assertPatJwtActive.mockResolvedValue({ scopes: ['usage:write'] }); + + const request: Record = { headers: { authorization: 'Bearer pat-jwt' } }; + + await expect(guard.canActivate(createContext(request))).resolves.toBe(true); + expect(request.patAuthenticated).toBe(true); + expect(request.user).toMatchObject({ + id: 'u1', + amr: ['pat'], + scopes: ['usage:write'], + patId: 't1', + roles: [UserRole.ADMIN], + }); + }); + + it('rejects revoked or inactive PAT JWTs', async () => { + jwtService.verifyAsync.mockResolvedValue({ + sub: 'u1', + amr: ['pat'], + patId: 't1', + scopes: ['usage:write'], + tv: 0, + }); + usersRepository.findById.mockResolvedValue({ + id: 'u1', + email: 'a@b.c', + role: UserRole.ADMIN, + lockedAt: null, + tenantId: 'default', + tokenVersion: 0, + }); + personalAccessTokenService.assertPatJwtActive.mockRejectedValue( + new UnauthorizedException('Session is no longer valid.'), + ); + + const request = { headers: { authorization: 'Bearer pat-jwt' } }; + + await expect(guard.canActivate(createContext(request))).rejects.toThrow(UnauthorizedException); + }); +}); diff --git a/libs/domains/identity/backend/feature-auth/src/lib/guards/pat-bearer-auth.guard.ts b/libs/domains/identity/backend/feature-auth/src/lib/guards/pat-bearer-auth.guard.ts new file mode 100644 index 000000000..51eb90fab --- /dev/null +++ b/libs/domains/identity/backend/feature-auth/src/lib/guards/pat-bearer-auth.guard.ts @@ -0,0 +1,143 @@ +import { + getAuthenticationMethod, + getHttpRequestPath, + isBullBoardRequestPath, + IS_PUBLIC_KEY, +} from '@forepath/identity/backend'; +import { DEFAULT_TENANT, getTenantId, getTenantIdOrDefault } from '@forepath/shared/backend'; +import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { JwtService } from '@nestjs/jwt'; +import { Request } from 'express'; + +import { RevokedUserTokensRepository } from '../repositories/revoked-user-tokens.repository'; +import { UsersRepository } from '../repositories/users.repository'; +import { PersonalAccessTokenService } from '../services/personal-access-token.service'; +import { assertUsersJwtSessionValid, type UsersJwtSessionPayload } from '../utils/users-jwt-session.util'; + +import type { AuthenticatedUsersRequestUser } from './users-auth.guard'; + +/** + * Keycloak-mode hybrid: accept app-signed PAT JWTs (`amr: pat`) before nest-keycloak AuthGuard. + * Non-PAT bearers pass through so Keycloak OIDC validation can run. + */ +@Injectable() +export class PatBearerAuthGuard implements CanActivate { + constructor( + private readonly jwtService: JwtService, + private readonly reflector: Reflector, + private readonly usersRepository: UsersRepository, + private readonly revokedUserTokensRepository: RevokedUserTokensRepository, + private readonly personalAccessTokenService: PersonalAccessTokenService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + if (getAuthenticationMethod() !== 'keycloak') { + return true; + } + + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + + if (isBullBoardRequestPath(getHttpRequestPath(context))) { + return true; + } + + const request = context.switchToHttp().getRequest< + Request & { + tenantId?: string; + patAuthenticated?: boolean; + apiKeyAuthenticated?: boolean; + user?: AuthenticatedUsersRequestUser; + } + >(); + + if (request.patAuthenticated || request.apiKeyAuthenticated) { + return true; + } + + const token = this.extractTokenFromHeader(request); + + if (!token) { + return true; + } + + let payload: UsersJwtSessionPayload; + + try { + payload = await this.jwtService.verifyAsync(token); + } catch { + // Not an app-signed JWT (likely Keycloak access token) — let Keycloak guards handle it. + return true; + } + + const amr = payload.amr ?? []; + + if (!amr.includes('pat')) { + return true; + } + + try { + const user = await this.usersRepository.findById(payload.sub); + + if (!user) { + throw new UnauthorizedException('Session is no longer valid.'); + } + + if (user.lockedAt) { + throw new UnauthorizedException('This account is locked. Please contact an administrator.'); + } + + const requestTenantId = request.tenantId ?? getTenantId() ?? getTenantIdOrDefault(); + const userTenantId = user.tenantId?.trim() || DEFAULT_TENANT; + + if (userTenantId !== requestTenantId) { + throw new UnauthorizedException('Session is no longer valid.'); + } + + await assertUsersJwtSessionValid(payload, user, this.revokedUserTokensRepository); + + if (!payload.patId) { + throw new UnauthorizedException('Session is no longer valid.'); + } + + const active = await this.personalAccessTokenService.assertPatJwtActive( + payload.patId, + payload.sub, + payload.scopes, + ); + + request.patAuthenticated = true; + request.user = { + id: payload.sub, + email: user.email, + roles: [user.role], + amr, + scopes: active.scopes, + patId: payload.patId, + jti: payload.jti, + exp: typeof payload.exp === 'number' ? payload.exp : undefined, + } satisfies AuthenticatedUsersRequestUser; + + return true; + } catch (error) { + if (error instanceof UnauthorizedException) { + throw error; + } + + throw new UnauthorizedException('Invalid or expired token'); + } + } + + private extractTokenFromHeader(request: Request): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? []; + + return type === 'Bearer' ? token : undefined; + } +} diff --git a/libs/domains/identity/backend/feature-auth/src/lib/guards/pat-scopes.guard.spec.ts b/libs/domains/identity/backend/feature-auth/src/lib/guards/pat-scopes.guard.spec.ts new file mode 100644 index 000000000..f18dc9d61 --- /dev/null +++ b/libs/domains/identity/backend/feature-auth/src/lib/guards/pat-scopes.guard.spec.ts @@ -0,0 +1,138 @@ +import { IS_PUBLIC_KEY } from '@forepath/identity/backend'; +import { ForbiddenException, UnauthorizedException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; + +import { REQUIRE_PASSWORD_SESSION_KEY, REQUIRE_SCOPES_KEY } from '../constants/pat.constants'; + +import { PatScopesGuard } from './pat-scopes.guard'; + +describe('PatScopesGuard', () => { + let guard: PatScopesGuard; + let reflector: { getAllAndOverride: jest.Mock }; + let originalAuthMethod: string | undefined; + const createContext = (user?: Record) => + ({ + switchToHttp: () => ({ + getRequest: () => ({ user }), + }), + getHandler: () => ({}), + getClass: () => ({}), + }) as never; + + beforeEach(() => { + originalAuthMethod = process.env.AUTHENTICATION_METHOD; + process.env.AUTHENTICATION_METHOD = 'users'; + reflector = { getAllAndOverride: jest.fn() }; + guard = new PatScopesGuard(reflector as unknown as Reflector); + }); + + afterEach(() => { + if (originalAuthMethod !== undefined) { + process.env.AUTHENTICATION_METHOD = originalAuthMethod; + } else { + delete process.env.AUTHENTICATION_METHOD; + } + }); + + it('allows password sessions without scopes', () => { + reflector.getAllAndOverride.mockImplementation((key: string) => { + if (key === IS_PUBLIC_KEY) { + return false; + } + + if (key === REQUIRE_PASSWORD_SESSION_KEY) { + return false; + } + + if (key === REQUIRE_SCOPES_KEY) { + return undefined; + } + + return undefined; + }); + + expect(guard.canActivate(createContext({ id: 'u1', amr: ['pwd'] }))).toBe(true); + }); + + it('fail-closes PAT requests without @RequireScopes', () => { + reflector.getAllAndOverride.mockImplementation((key: string) => { + if (key === REQUIRE_SCOPES_KEY) { + return undefined; + } + + return false; + }); + + expect(() => guard.canActivate(createContext({ id: 'u1', amr: ['pat'], scopes: ['usage:write'] }))).toThrow( + ForbiddenException, + ); + }); + + it('allows PAT when required scopes are present', () => { + reflector.getAllAndOverride.mockImplementation((key: string) => { + if (key === REQUIRE_SCOPES_KEY) { + return ['usage:write']; + } + + return false; + }); + + expect( + guard.canActivate(createContext({ id: 'u1', amr: ['pat'], scopes: ['usage:write', 'webhooks:admin'] })), + ).toBe(true); + }); + + it('rejects PAT missing a required scope', () => { + reflector.getAllAndOverride.mockImplementation((key: string) => { + if (key === REQUIRE_SCOPES_KEY) { + return ['webhooks:admin']; + } + + return false; + }); + + expect(() => guard.canActivate(createContext({ id: 'u1', amr: ['pat'], scopes: ['usage:write'] }))).toThrow( + /Insufficient token scope/, + ); + }); + + it('rejects PAT on @RequirePasswordSession routes', () => { + reflector.getAllAndOverride.mockImplementation((key: string) => { + if (key === REQUIRE_PASSWORD_SESSION_KEY) { + return true; + } + + return false; + }); + + expect(() => guard.canActivate(createContext({ id: 'u1', amr: ['pat'], scopes: ['users:admin'] }))).toThrow( + /cannot access this endpoint/, + ); + }); + + it('rejects unauthenticated requests on non-public routes', () => { + reflector.getAllAndOverride.mockReturnValue(false); + + expect(() => guard.canActivate(createContext(undefined))).toThrow(UnauthorizedException); + }); + + it('enforces scopes under keycloak mode', () => { + process.env.AUTHENTICATION_METHOD = 'keycloak'; + reflector.getAllAndOverride.mockImplementation((key: string) => { + if (key === REQUIRE_SCOPES_KEY) { + return ['usage:write']; + } + + return false; + }); + + expect(guard.canActivate(createContext({ id: 'u1', amr: ['pat'], scopes: ['usage:write'] }))).toBe(true); + }); + + it('no-ops under api-key mode', () => { + process.env.AUTHENTICATION_METHOD = 'api-key'; + reflector.getAllAndOverride.mockReturnValue(false); + + expect(guard.canActivate(createContext({ id: 'u1', amr: ['pat'] }))).toBe(true); + }); +}); diff --git a/libs/domains/identity/backend/feature-auth/src/lib/guards/pat-scopes.guard.ts b/libs/domains/identity/backend/feature-auth/src/lib/guards/pat-scopes.guard.ts new file mode 100644 index 000000000..3a6cfbc21 --- /dev/null +++ b/libs/domains/identity/backend/feature-auth/src/lib/guards/pat-scopes.guard.ts @@ -0,0 +1,73 @@ +import { getAuthenticationMethod, IS_PUBLIC_KEY } from '@forepath/identity/backend'; +import { CanActivate, ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; + +import { REQUIRE_PASSWORD_SESSION_KEY, REQUIRE_SCOPES_KEY } from '../constants/pat.constants'; + +import type { AuthenticatedUsersRequestUser } from './users-auth.guard'; + +/** + * Enforces PAT scopes (fail-closed for `amr: pat`) and interactive-session-only routes. + * Interactive console sessions skip scope checks. Must run after auth guards set `request.user`. + */ +@Injectable() +export class PatScopesGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const authMethod = getAuthenticationMethod(); + + if (authMethod !== 'users' && authMethod !== 'keycloak') { + return true; + } + + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + + const request = context.switchToHttp().getRequest<{ user?: AuthenticatedUsersRequestUser }>(); + const user = request.user; + + if (!user) { + throw new UnauthorizedException('Authentication required.'); + } + + const amr = user.amr ?? ['pwd']; + const isPat = amr.includes('pat'); + const requireInteractiveSession = this.reflector.getAllAndOverride(REQUIRE_PASSWORD_SESSION_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (requireInteractiveSession && isPat) { + throw new ForbiddenException('Personal access tokens cannot access this endpoint.'); + } + + if (!isPat) { + return true; + } + + const requiredScopes = this.reflector.getAllAndOverride(REQUIRE_SCOPES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (!requiredScopes || requiredScopes.length === 0) { + throw new ForbiddenException('Personal access token is not permitted for this endpoint.'); + } + + const tokenScopes = new Set(user.scopes ?? []); + const missing = requiredScopes.filter((scope) => !tokenScopes.has(scope)); + + if (missing.length > 0) { + throw new ForbiddenException(`Insufficient token scope. Missing: ${missing.join(', ')}`); + } + + return true; + } +} diff --git a/libs/domains/identity/backend/feature-auth/src/lib/guards/users-auth.guard.spec.ts b/libs/domains/identity/backend/feature-auth/src/lib/guards/users-auth.guard.spec.ts index b93700713..203450470 100644 --- a/libs/domains/identity/backend/feature-auth/src/lib/guards/users-auth.guard.spec.ts +++ b/libs/domains/identity/backend/feature-auth/src/lib/guards/users-auth.guard.spec.ts @@ -1,5 +1,6 @@ import { UserRole } from '@forepath/identity/backend'; import { runWithTenantId } from '@forepath/shared/backend'; +import { UnauthorizedException } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { JwtService } from '@nestjs/jwt'; @@ -32,12 +33,16 @@ describe('UsersAuthGuard', () => { reflector = { getAllAndOverride: jest.fn().mockReturnValue(false) }; usersRepository = { findById: jest.fn() }; revokedUserTokensRepository = { isRevoked: jest.fn().mockResolvedValue(false) }; + const personalAccessTokenService = { + assertPatJwtActive: jest.fn().mockResolvedValue({ scopes: ['usage:write'] }), + }; guard = new UsersAuthGuard( jwtService as unknown as JwtService, reflector as unknown as Reflector, usersRepository as unknown as UsersRepository, revokedUserTokensRepository as unknown as RevokedUserTokensRepository, + personalAccessTokenService as never, ); }); @@ -63,6 +68,8 @@ describe('UsersAuthGuard', () => { }); usersRepository.findById.mockResolvedValue({ id: 'user-1', + email: 'a@b.com', + role: UserRole.USER, tenantId: 'default', lockedAt: null, tokenVersion: 1, @@ -83,6 +90,8 @@ describe('UsersAuthGuard', () => { }); usersRepository.findById.mockResolvedValue({ id: 'user-1', + email: 'a@b.com', + role: UserRole.USER, tenantId: 'default', lockedAt: null, tokenVersion: 1, @@ -136,9 +145,58 @@ describe('UsersAuthGuard', () => { id: 'user-1', email: 'a@b.com', roles: [UserRole.USER], + amr: ['pwd'], + scopes: undefined, + patId: undefined, + jti: undefined, + exp: undefined, }); }); + it('uses live DB role instead of JWT roles claim (demotion)', async () => { + const request = { headers: { authorization: 'Bearer valid.jwt.token' } }; + const personalAccessTokenService = { + assertPatJwtActive: jest.fn().mockResolvedValue({ scopes: ['invoices:read'] }), + }; + + guard = new UsersAuthGuard( + jwtService as unknown as JwtService, + reflector as unknown as Reflector, + usersRepository as unknown as UsersRepository, + revokedUserTokensRepository as unknown as RevokedUserTokensRepository, + personalAccessTokenService as never, + ); + + jwtService.verifyAsync.mockResolvedValue({ + sub: 'user-1', + email: 'a@b.com', + roles: [UserRole.ADMIN], + amr: ['pat'], + scopes: ['invoices:read'], + patId: 'pat-1', + tv: 1, + }); + usersRepository.findById.mockResolvedValue({ + id: 'user-1', + email: 'a@b.com', + role: UserRole.USER, + tenantId: 'default', + lockedAt: null, + tokenVersion: 1, + } as never); + + const ok = await guard.canActivate(createExecutionContext(request)); + + expect(ok).toBe(true); + expect(request['user']).toEqual( + expect.objectContaining({ + roles: [UserRole.USER], + amr: ['pat'], + scopes: ['invoices:read'], + }), + ); + }); + it('rejects when user tenant does not match request tenant', async () => { const request = { headers: { authorization: 'Bearer valid.jwt.token' } }; @@ -221,10 +279,27 @@ describe('UsersAuthGuard', () => { expect(jwtService.verifyAsync).not.toHaveBeenCalled(); }); - it('skips JWT validation when authentication method is not users', async () => { + it('under keycloak requires synced user id without verifying JWT locally', async () => { process.env.AUTHENTICATION_METHOD = 'keycloak'; - const ok = await guard.canActivate(createExecutionContext({ headers: {} })); + await expect(guard.canActivate(createExecutionContext({ headers: {} }))).rejects.toThrow( + 'Missing or invalid authorization token', + ); + + const ok = await guard.canActivate( + createExecutionContext({ headers: {}, user: { id: 'synced-user', roles: ['admin'] } }), + ); + + expect(ok).toBe(true); + expect(jwtService.verifyAsync).not.toHaveBeenCalled(); + }); + + it('under keycloak accepts patAuthenticated sessions', async () => { + process.env.AUTHENTICATION_METHOD = 'keycloak'; + + const ok = await guard.canActivate( + createExecutionContext({ headers: {}, patAuthenticated: true, user: { id: 'u1', amr: ['pat'] } }), + ); expect(ok).toBe(true); expect(jwtService.verifyAsync).not.toHaveBeenCalled(); @@ -244,6 +319,43 @@ describe('UsersAuthGuard', () => { await expect(guard.canActivate(createExecutionContext(request))).rejects.toThrow('Invalid or expired token'); }); + it('rejects PAT JWT when underlying token was revoked', async () => { + const request = { headers: { authorization: 'Bearer valid.jwt.token' } }; + const personalAccessTokenService = { + assertPatJwtActive: jest.fn().mockRejectedValue(new UnauthorizedException('Session is no longer valid.')), + }; + + guard = new UsersAuthGuard( + jwtService as unknown as JwtService, + reflector as unknown as Reflector, + usersRepository as unknown as UsersRepository, + revokedUserTokensRepository as unknown as RevokedUserTokensRepository, + personalAccessTokenService as never, + ); + + jwtService.verifyAsync.mockResolvedValue({ + sub: 'user-1', + email: 'a@b.com', + roles: [UserRole.ADMIN], + amr: ['pat'], + scopes: ['usage:write'], + patId: 'pat-1', + tv: 1, + jti: 'session-1', + }); + usersRepository.findById.mockResolvedValue({ + id: 'user-1', + email: 'a@b.com', + role: UserRole.USER, + tenantId: 'default', + lockedAt: null, + tokenVersion: 1, + } as never); + + await expect(guard.canActivate(createExecutionContext(request))).rejects.toThrow('Session is no longer valid.'); + expect(personalAccessTokenService.assertPatJwtActive).toHaveBeenCalledWith('pat-1', 'user-1', ['usage:write']); + }); + it('uses request.tenantId when validating user tenant', async () => { const request = { headers: { authorization: 'Bearer valid.jwt.token' }, diff --git a/libs/domains/identity/backend/feature-auth/src/lib/guards/users-auth.guard.ts b/libs/domains/identity/backend/feature-auth/src/lib/guards/users-auth.guard.ts index 92c53b145..02b02d2de 100644 --- a/libs/domains/identity/backend/feature-auth/src/lib/guards/users-auth.guard.ts +++ b/libs/domains/identity/backend/feature-auth/src/lib/guards/users-auth.guard.ts @@ -12,6 +12,7 @@ import { Request } from 'express'; import { RevokedUserTokensRepository } from '../repositories/revoked-user-tokens.repository'; import { UsersRepository } from '../repositories/users.repository'; +import { PersonalAccessTokenService } from '../services/personal-access-token.service'; import { assertUsersJwtSessionValid, UsersJwtSessionPayload } from '../utils/users-jwt-session.util'; export type UsersJwtPayload = UsersJwtSessionPayload; @@ -20,6 +21,9 @@ export interface AuthenticatedUsersRequestUser { id: string; email: string; roles: string[]; + amr?: string[]; + scopes?: string[]; + patId?: string; jti?: string; exp?: number; } @@ -31,6 +35,7 @@ export class UsersAuthGuard implements CanActivate { private readonly reflector: Reflector, private readonly usersRepository: UsersRepository, private readonly revokedUserTokensRepository: RevokedUserTokensRepository, + private readonly personalAccessTokenService: PersonalAccessTokenService, ) {} async canActivate(context: ExecutionContext): Promise { @@ -43,19 +48,37 @@ export class UsersAuthGuard implements CanActivate { return true; } - if (getAuthenticationMethod() !== 'users') { - return true; - } - // Bull Board uses HTTP Basic auth (QUEUE_BULL_BOARD_*), not JWT if (isBullBoardRequestPath(getHttpRequestPath(context))) { return true; } - const request = context.switchToHttp().getRequest(); + const authMethod = getAuthenticationMethod(); + const request = context.switchToHttp().getRequest< + Request & { + tenantId?: string; + patAuthenticated?: boolean; + apiKeyAuthenticated?: boolean; + user?: AuthenticatedUsersRequestUser | { id?: string }; + } + >(); + + // Keycloak / api-key: global guards authenticate. When this guard is used at controller + // level (e.g. PAT CRUD), require a resolved local user id (synced OIDC or PAT). + if (authMethod === 'keycloak') { + if (request.patAuthenticated || request.user?.id) { + return true; + } + + throw new UnauthorizedException('Missing or invalid authorization token'); + } + + if (authMethod !== 'users') { + return true; + } // Pass through when user is already set (e.g. by API key or other auth) - if (request['user']) { + if (request.user) { return true; } @@ -86,10 +109,31 @@ export class UsersAuthGuard implements CanActivate { await assertUsersJwtSessionValid(payload, user, this.revokedUserTokensRepository); - request['user'] = { + const amr = payload.amr ?? ['pwd']; + let scopes = payload.scopes; + + if (amr.includes('pat')) { + if (!payload.patId) { + throw new UnauthorizedException('Session is no longer valid.'); + } + + const active = await this.personalAccessTokenService.assertPatJwtActive( + payload.patId, + payload.sub, + payload.scopes, + ); + + scopes = active.scopes; + } + + request.user = { id: payload.sub, - email: payload.email, - roles: payload.roles ?? ['user'], + // Always use live DB role so demotion takes effect without waiting for JWT expiry. + email: user.email, + roles: [user.role], + amr, + scopes, + patId: payload.patId, jti: payload.jti, exp: typeof payload.exp === 'number' ? payload.exp : undefined, } satisfies AuthenticatedUsersRequestUser; diff --git a/libs/domains/identity/backend/feature-auth/src/lib/guards/users-roles.guard.spec.ts b/libs/domains/identity/backend/feature-auth/src/lib/guards/users-roles.guard.spec.ts index ed309ad8d..aaa4e70de 100644 --- a/libs/domains/identity/backend/feature-auth/src/lib/guards/users-roles.guard.spec.ts +++ b/libs/domains/identity/backend/feature-auth/src/lib/guards/users-roles.guard.spec.ts @@ -52,4 +52,38 @@ describe('UsersRolesGuard', () => { ), ).toBe(true); }); + + it('under keycloak defers when interactive token has no roles array', () => { + process.env.AUTHENTICATION_METHOD = 'keycloak'; + + expect( + guard.canActivate( + createExecutionContext({ + user: { sub: 'kc-sub', realm_access: { roles: ['admin'] } }, + }), + ), + ).toBe(true); + }); + + it('under keycloak enforces roles for PAT JWTs', () => { + process.env.AUTHENTICATION_METHOD = 'keycloak'; + + expect( + guard.canActivate( + createExecutionContext({ + patAuthenticated: true, + user: { id: 'u1', roles: [UserRole.USER] }, + }), + ), + ).toBe(false); + + expect( + guard.canActivate( + createExecutionContext({ + patAuthenticated: true, + user: { id: 'u1', roles: [UserRole.ADMIN] }, + }), + ), + ).toBe(true); + }); }); diff --git a/libs/domains/identity/backend/feature-auth/src/lib/guards/users-roles.guard.ts b/libs/domains/identity/backend/feature-auth/src/lib/guards/users-roles.guard.ts index 532cbe9d3..86efff9cb 100644 --- a/libs/domains/identity/backend/feature-auth/src/lib/guards/users-roles.guard.ts +++ b/libs/domains/identity/backend/feature-auth/src/lib/guards/users-roles.guard.ts @@ -23,7 +23,7 @@ export class UsersRolesGuard implements CanActivate { return true; } - if (getAuthenticationMethod() !== 'users') { + if (getAuthenticationMethod() === 'api-key') { return true; } @@ -40,8 +40,19 @@ export class UsersRolesGuard implements CanActivate { return true; } - const { user } = context.switchToHttp().getRequest(); - const roles = user?.roles; + const request = context.switchToHttp().getRequest<{ + user?: { roles?: unknown }; + patAuthenticated?: boolean; + }>(); + const roles = request.user?.roles; + + // Interactive Keycloak tokens use realm_access until KeycloakAuthGuard syncs local roles. + // Defer to KeycloakRolesGuard (`@KeycloakRoles`) in that case. + // Enforce `@UsersRoles` for: users-mode JWTs, and Keycloak-mode PAT JWTs (`patAuthenticated`). + // New admin routes under keycloak should set BOTH `@KeycloakRoles` and `@UsersRoles`. + if (getAuthenticationMethod() === 'keycloak' && !request.patAuthenticated && !Array.isArray(roles)) { + return true; + } if (!Array.isArray(roles)) { return false; diff --git a/libs/domains/identity/backend/feature-auth/src/lib/modules/keycloak-user-sync.module.ts b/libs/domains/identity/backend/feature-auth/src/lib/modules/keycloak-user-sync.module.ts index a3662f113..a36f2a0c6 100644 --- a/libs/domains/identity/backend/feature-auth/src/lib/modules/keycloak-user-sync.module.ts +++ b/libs/domains/identity/backend/feature-auth/src/lib/modules/keycloak-user-sync.module.ts @@ -1,6 +1,5 @@ import { UserEntity } from '@forepath/identity/backend'; import { Module } from '@nestjs/common'; -import { APP_GUARD } from '@nestjs/core'; import { JwtModule } from '@nestjs/jwt'; import { TypeOrmModule } from '@nestjs/typeorm'; @@ -13,16 +12,11 @@ import { UsersService } from '../services/users.service'; import { resolveJwtModuleSecret } from '../utils/resolve-jwt-module-secret'; /** - * Module that syncs Keycloak-authenticated users to the users table. - * Provides UsersController for user management when Keycloak is active. + * Syncs Keycloak-authenticated users to the users table and provides UsersController. * Only load when AUTHENTICATION_METHOD=keycloak. * - * Transactional email (if any) is enqueued via IDENTITY_EMAIL_DISPATCHER from the host app. - * - * To enable statistics tracking, the consuming module should provide: - * ```ts - * { provide: IDENTITY_STATISTICS_SERVICE, useExisting: YourStatisticsService } - * ``` + * Do NOT register KeycloakAuthGuard / KeycloakRolesGuard as APP_GUARD here — + * host apps must use `getKeycloakPatAuthGuards()` so PatBearer runs first. */ @Module({ imports: [ @@ -34,14 +28,7 @@ import { resolveJwtModuleSecret } from '../utils/resolve-jwt-module-secret'; }), ], controllers: [UsersController], - providers: [ - UsersRepository, - UsersService, - UsersAuthGuard, - KeycloakRolesGuard, - { provide: APP_GUARD, useClass: KeycloakAuthGuard }, - { provide: APP_GUARD, useClass: KeycloakRolesGuard }, - ], - exports: [UsersRepository], + providers: [UsersRepository, UsersService, UsersAuthGuard, KeycloakAuthGuard, KeycloakRolesGuard], + exports: [UsersRepository, UsersService, KeycloakAuthGuard, KeycloakRolesGuard], }) export class KeycloakUserSyncModule {} diff --git a/libs/domains/identity/backend/feature-auth/src/lib/modules/pat-auth.module.ts b/libs/domains/identity/backend/feature-auth/src/lib/modules/pat-auth.module.ts new file mode 100644 index 000000000..1c02e10c2 --- /dev/null +++ b/libs/domains/identity/backend/feature-auth/src/lib/modules/pat-auth.module.ts @@ -0,0 +1,67 @@ +import { RevokedUserTokenEntity, UserEntity, UserPersonalAccessTokenEntity } from '@forepath/identity/backend'; +import { DynamicModule, Module, type Provider } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { IDENTITY_PAT_SCOPE_CATALOG } from '../constants/pat.constants'; +import { PatTokenExchangeController } from '../controllers/pat-token-exchange.controller'; +import { PersonalAccessTokensController } from '../controllers/personal-access-tokens.controller'; +import { PatBearerAuthGuard } from '../guards/pat-bearer-auth.guard'; +import { PatScopesGuard } from '../guards/pat-scopes.guard'; +import { UsersAuthGuard } from '../guards/users-auth.guard'; +import { UsersRolesGuard } from '../guards/users-roles.guard'; +import { PersonalAccessTokensRepository } from '../repositories/personal-access-tokens.repository'; +import { RevokedUserTokensRepository } from '../repositories/revoked-user-tokens.repository'; +import { UsersRepository } from '../repositories/users.repository'; +import { AuthService } from '../services/auth.service'; +import { PersonalAccessTokenService } from '../services/personal-access-token.service'; +import { UsersService } from '../services/users.service'; + +export interface PatAuthModuleOptions { + /** Product PAT scope allowlist (mirrors webhook eventCatalog). */ + patScopeCatalog: readonly string[]; +} + +/** + * Shared PAT stack for users-mode and keycloak-mode. + * Host must register JwtModule (UsersAuthModule or KeycloakUserSyncModule). + * APP_GUARD registration for PatScopesGuard / UsersRolesGuard / PatBearerAuthGuard + * is owned by the host module so guard order stays correct. + */ +@Module({}) +export class PatAuthModule { + static register(options: PatAuthModuleOptions): DynamicModule { + const providers: Provider[] = [ + { provide: IDENTITY_PAT_SCOPE_CATALOG, useValue: options.patScopeCatalog }, + UsersRepository, + UsersService, + RevokedUserTokensRepository, + PersonalAccessTokensRepository, + PersonalAccessTokenService, + AuthService, + UsersAuthGuard, + PatBearerAuthGuard, + UsersRolesGuard, + PatScopesGuard, + ]; + + return { + module: PatAuthModule, + imports: [TypeOrmModule.forFeature([UserEntity, RevokedUserTokenEntity, UserPersonalAccessTokenEntity])], + controllers: [PersonalAccessTokensController, PatTokenExchangeController], + providers, + exports: [ + UsersRepository, + UsersService, + RevokedUserTokensRepository, + PersonalAccessTokensRepository, + PersonalAccessTokenService, + AuthService, + UsersAuthGuard, + PatBearerAuthGuard, + UsersRolesGuard, + PatScopesGuard, + IDENTITY_PAT_SCOPE_CATALOG, + ], + }; + } +} diff --git a/libs/domains/identity/backend/feature-auth/src/lib/modules/users-auth.module.ts b/libs/domains/identity/backend/feature-auth/src/lib/modules/users-auth.module.ts index ca7fac253..093c80787 100644 --- a/libs/domains/identity/backend/feature-auth/src/lib/modules/users-auth.module.ts +++ b/libs/domains/identity/backend/feature-auth/src/lib/modules/users-auth.module.ts @@ -1,5 +1,5 @@ -import { RevokedUserTokenEntity, UserEntity } from '@forepath/identity/backend'; -import { Module } from '@nestjs/common'; +import { RevokedUserTokenEntity, UserEntity, UserPersonalAccessTokenEntity } from '@forepath/identity/backend'; +import { DynamicModule, Module, type Provider } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; import { JwtModule } from '@nestjs/jwt'; import { TypeOrmModule } from '@nestjs/typeorm'; @@ -7,47 +7,51 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { AuthController } from '../controllers/auth.controller'; import { UsersController } from '../controllers/users.controller'; import { KeycloakRolesGuard } from '../guards/keycloak-roles.guard'; +import { PatScopesGuard } from '../guards/pat-scopes.guard'; import { UsersAuthGuard } from '../guards/users-auth.guard'; import { UsersRolesGuard } from '../guards/users-roles.guard'; -import { RevokedUserTokensRepository } from '../repositories/revoked-user-tokens.repository'; -import { UsersRepository } from '../repositories/users.repository'; -import { AuthService } from '../services/auth.service'; -import { UsersService } from '../services/users.service'; import { resolveJwtModuleSecret } from '../utils/resolve-jwt-module-secret'; +import { PatAuthModule, type PatAuthModuleOptions } from './pat-auth.module'; + +export type UsersAuthModuleOptions = PatAuthModuleOptions; + /** * Module for "users" authentication method. - * Provides JWT-based auth with user registration, email confirmation, password reset. + * Provides JWT-based auth with user registration, email confirmation, password reset, and PATs. * Only load this module when AUTHENTICATION_METHOD=users. * - * Transactional email is enqueued via IDENTITY_EMAIL_DISPATCHER (provided by the host app). - * - * To enable statistics tracking, the consuming module should provide: * ```ts - * { provide: IDENTITY_STATISTICS_SERVICE, useExisting: YourStatisticsService } + * UsersAuthModule.register({ patScopeCatalog: AGENSTRA_PAT_SCOPES }) * ``` */ -@Module({ - imports: [ - TypeOrmModule.forFeature([UserEntity, RevokedUserTokenEntity]), - JwtModule.register({ - global: true, - secret: resolveJwtModuleSecret('users'), - signOptions: { expiresIn: '7d' }, - }), - ], - controllers: [AuthController, UsersController], - providers: [ - UsersRepository, - RevokedUserTokensRepository, - UsersService, - AuthService, - UsersAuthGuard, - KeycloakRolesGuard, - UsersRolesGuard, - { provide: APP_GUARD, useClass: UsersAuthGuard }, - { provide: APP_GUARD, useClass: UsersRolesGuard }, - ], - exports: [UsersService, UsersRepository, RevokedUserTokensRepository, AuthService], -}) -export class UsersAuthModule {} +@Module({}) +export class UsersAuthModule { + static register(options: UsersAuthModuleOptions): DynamicModule { + const providers: Provider[] = [ + UsersAuthGuard, + KeycloakRolesGuard, + UsersRolesGuard, + PatScopesGuard, + { provide: APP_GUARD, useClass: UsersAuthGuard }, + { provide: APP_GUARD, useClass: UsersRolesGuard }, + { provide: APP_GUARD, useClass: PatScopesGuard }, + ]; + + return { + module: UsersAuthModule, + imports: [ + PatAuthModule.register({ patScopeCatalog: options.patScopeCatalog }), + TypeOrmModule.forFeature([UserEntity, RevokedUserTokenEntity, UserPersonalAccessTokenEntity]), + JwtModule.register({ + global: true, + secret: resolveJwtModuleSecret('users'), + signOptions: { expiresIn: '7d' }, + }), + ], + controllers: [AuthController, UsersController], + providers, + exports: [PatAuthModule], + }; + } +} diff --git a/libs/domains/identity/backend/feature-auth/src/lib/repositories/personal-access-tokens.repository.ts b/libs/domains/identity/backend/feature-auth/src/lib/repositories/personal-access-tokens.repository.ts new file mode 100644 index 000000000..36c84ac08 --- /dev/null +++ b/libs/domains/identity/backend/feature-auth/src/lib/repositories/personal-access-tokens.repository.ts @@ -0,0 +1,52 @@ +import { UserPersonalAccessTokenEntity } from '@forepath/identity/backend'; +import { InjectRepository } from '@nestjs/typeorm'; +import { IsNull, Repository } from 'typeorm'; + +export class PersonalAccessTokensRepository { + constructor( + @InjectRepository(UserPersonalAccessTokenEntity) + private readonly repository: Repository, + ) {} + + async create(data: Partial): Promise { + const entity = this.repository.create(data); + + return this.repository.save(entity); + } + + async findByPrefix(tokenPrefix: string): Promise { + return this.repository.findOne({ where: { tokenPrefix } }); + } + + async findById(id: string): Promise { + return this.repository.findOne({ where: { id } }); + } + + async findActiveByUserId(userId: string): Promise { + return this.repository.find({ + where: { userId, revokedAt: IsNull() }, + order: { createdAt: 'DESC' }, + }); + } + + async findAllByUserId(userId: string): Promise { + return this.repository.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + }); + } + + async save(entity: UserPersonalAccessTokenEntity): Promise { + return this.repository.save(entity); + } + + /** + * Touch lastUsedAt only if the token is still active (avoids resurrecting revoked rows). + * Returns true when a row was updated. + */ + async touchLastUsedAtIfActive(id: string, lastUsedAt: Date): Promise { + const result = await this.repository.update({ id, revokedAt: IsNull() }, { lastUsedAt }); + + return (result.affected ?? 0) > 0; + } +} diff --git a/libs/domains/identity/backend/feature-auth/src/lib/services/auth.service.spec.ts b/libs/domains/identity/backend/feature-auth/src/lib/services/auth.service.spec.ts index b0942e35f..c0a035224 100644 --- a/libs/domains/identity/backend/feature-auth/src/lib/services/auth.service.spec.ts +++ b/libs/domains/identity/backend/feature-auth/src/lib/services/auth.service.spec.ts @@ -22,6 +22,9 @@ describe('AuthService', () => { const mockJwtService = { sign: jest.fn().mockReturnValue('jwt-token'), }; + const mockPersonalAccessTokenService = { + verifyToken: jest.fn(), + }; const mockEmailDispatcher = { publishEmail: jest.fn(), }; @@ -35,6 +38,7 @@ describe('AuthService', () => { mockRevokedUserTokensRepository as any, mockUsersService as any, mockJwtService as any, + mockPersonalAccessTokenService as any, mockEmailDispatcher as any, ); }); @@ -63,19 +67,60 @@ describe('AuthService', () => { emailConfirmedAt: new Date('2026-01-01T00:00:00.000Z'), lockedAt: null, passwordHash: '$2b$12$hash', + tokenVersion: 0, }); mockUsersService.validatePassword.mockResolvedValue(true); const result = await service.login('active@example.com', 'password123'); expect(mockUsersService.validatePassword).toHaveBeenCalledWith('password123', '$2b$12$hash'); - expect(mockJwtService.sign).toHaveBeenCalled(); + expect(mockJwtService.sign).toHaveBeenCalledWith( + expect.objectContaining({ + sub: 'user-2', + amr: ['pwd'], + }), + expect.any(Object), + ); expect(result).toEqual({ access_token: 'jwt-token', user: { id: 'user-2', email: 'active@example.com', role: UserRole.ADMIN }, }); }); + it('rejects personal access token secrets on interactive login', async () => { + await expect(service.login('user@example.com', 'fp_pat_abcdefghijklmnop')).rejects.toThrow(UnauthorizedException); + expect(mockUsersRepository.findByEmail).not.toHaveBeenCalled(); + }); + + it('exchanges a personal access token for a machine JWT', async () => { + mockPersonalAccessTokenService.verifyToken.mockResolvedValue({ + user: { + id: 'user-3', + email: 'pat@example.com', + role: UserRole.ADMIN, + tokenVersion: 0, + }, + scopes: ['usage:write'], + patId: 'pat-1', + }); + + const result = await service.exchangePat('fp_pat_secret'); + + expect(mockJwtService.sign).toHaveBeenCalledWith( + expect.objectContaining({ + sub: 'user-3', + amr: ['pat'], + scopes: ['usage:write'], + }), + expect.any(Object), + ); + expect(result).toEqual({ + access_token: 'jwt-token', + user: { id: 'user-3', email: 'pat@example.com', role: UserRole.ADMIN }, + scopes: ['usage:write'], + }); + }); + it('keeps invalid credentials response for non-existing user', async () => { mockUsersRepository.findByEmail.mockResolvedValue(null); diff --git a/libs/domains/identity/backend/feature-auth/src/lib/services/auth.service.ts b/libs/domains/identity/backend/feature-auth/src/lib/services/auth.service.ts index 082280d4b..d3baba20c 100644 --- a/libs/domains/identity/backend/feature-auth/src/lib/services/auth.service.ts +++ b/libs/domains/identity/backend/feature-auth/src/lib/services/auth.service.ts @@ -20,9 +20,11 @@ import { import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; +import { DUMMY_PAT_BCRYPT_HASH, PAT_TOKEN_PREFIX } from '../constants/pat.constants'; import { RevokedUserTokensRepository } from '../repositories/revoked-user-tokens.repository'; import { UsersRepository } from '../repositories/users.repository'; +import { PersonalAccessTokenService } from './personal-access-token.service'; import { UsersService } from './users.service'; export interface LogoutOptions { @@ -37,6 +39,13 @@ const PASSWORD_RESET_TOKEN_EXPIRY_MS = 60 * 60 * 1000; // 1 hour export interface LoginResponse { access_token: string; user: { id: string; email: string; role: UserRole }; + scopes?: string[]; +} + +export interface GenerateTokenOptions { + amr: 'pwd' | 'pat'; + scopes?: string[]; + patId?: string; } export interface ChangePasswordResponse { @@ -60,12 +69,19 @@ export class AuthService { private readonly revokedUserTokensRepository: RevokedUserTokensRepository, private readonly usersService: UsersService, private readonly jwtService: JwtService, + private readonly personalAccessTokenService: PersonalAccessTokenService, @Optional() @Inject(IDENTITY_EMAIL_DISPATCHER) private readonly emailDispatcher: IIdentityEmailDispatcher | null, ) {} async login(email: string, password: string): Promise { + // PATs must use POST /auth/token — never accept them on interactive login. + if (password.startsWith(PAT_TOKEN_PREFIX)) { + await this.usersService.validatePassword(password, DUMMY_PAT_BCRYPT_HASH); + throw new UnauthorizedException('Invalid email or password'); + } + const user = await this.usersRepository.findByEmail(email); if (!user) { @@ -90,7 +106,7 @@ export class AuthService { throw new UnauthorizedException('Invalid email or password'); } - const accessToken = this.generateToken(user); + const accessToken = this.generateToken(user, { amr: 'pwd' }); return { access_token: accessToken, @@ -98,6 +114,21 @@ export class AuthService { }; } + async exchangePat(token: string): Promise { + const verified = await this.personalAccessTokenService.verifyToken(token); + const accessToken = this.generateToken(verified.user, { + amr: 'pat', + scopes: verified.scopes, + patId: verified.patId, + }); + + return { + access_token: accessToken, + user: { id: verified.user.id, email: verified.user.email, role: verified.user.role }, + scopes: verified.scopes, + }; + } + async register(email: string, password: string): Promise { if (process.env.DISABLE_SIGNUP === 'true') { throw new ServiceUnavailableException('Signup is disabled'); @@ -244,7 +275,7 @@ export class AuthService { return { message: 'Password changed successfully.', - access_token: this.generateToken(updatedUser), + access_token: this.generateToken(updatedUser, { amr: 'pwd' }), }; } @@ -264,12 +295,14 @@ export class AuthService { return this.usersRepository.incrementTokenVersion(userId); } - private generateToken(user: UserEntity): string { + private generateToken(user: UserEntity, options: GenerateTokenOptions): string { return this.jwtService.sign( { sub: user.id, email: user.email, roles: [user.role], + amr: [options.amr], + ...(options.amr === 'pat' && options.scopes ? { scopes: options.scopes, patId: options.patId } : {}), tv: user.tokenVersion ?? 0, }, { expiresIn: JWT_EXPIRES_IN, jwtid: randomUUID() }, diff --git a/libs/domains/identity/backend/feature-auth/src/lib/services/client-users.service.ts b/libs/domains/identity/backend/feature-auth/src/lib/services/client-users.service.ts index 899049ebd..ac41c3db2 100644 --- a/libs/domains/identity/backend/feature-auth/src/lib/services/client-users.service.ts +++ b/libs/domains/identity/backend/feature-auth/src/lib/services/client-users.service.ts @@ -49,6 +49,7 @@ export class ClientUsersService { requestingUserRole: UserRole, isClientCreator: boolean, requestingClientUserRole?: ClientUserRole, + options?: { amr?: string[] }, ): Promise { // Find user by email const user = await this.usersRepository.findByEmail(addClientUserDto.email); @@ -65,11 +66,13 @@ export class ClientUsersService { } // Permission checks: - // - Global admins can add any role + // - Console global admins can add any role (PAT must not inherit this via role alone) // - Client creators can add any role // - Client admins can only add 'user' role // - Client users cannot add anyone - if (requestingUserRole === UserRole.ADMIN || isClientCreator) { + const isConsoleAdmin = requestingUserRole === UserRole.ADMIN && !(options?.amr ?? []).includes('pat'); + + if (isConsoleAdmin || isClientCreator) { // Global admin or creator: can add any role } else if (requestingClientUserRole === ClientUserRole.ADMIN) { // Client admin: can only add 'user' role @@ -133,6 +136,7 @@ export class ClientUsersService { requestingUserRole: UserRole, isClientCreator: boolean, requestingClientUserRole?: ClientUserRole, + options?: { amr?: string[] }, ): Promise { // Find the relationship const relationship = await this.clientUsersRepository.findByIdOrThrow(relationshipId); @@ -143,11 +147,13 @@ export class ClientUsersService { } // Permission checks: - // - Global admins can remove anyone + // - Console global admins can remove anyone (PAT must not inherit this via role alone) // - Client creators can remove anyone // - Client admins can remove users (but not other admins) // - Client users cannot remove anyone - if (requestingUserRole === UserRole.ADMIN || isClientCreator) { + const isConsoleAdmin = requestingUserRole === UserRole.ADMIN && !(options?.amr ?? []).includes('pat'); + + if (isConsoleAdmin || isClientCreator) { // Global admin or creator: can remove anyone } else if (requestingClientUserRole === ClientUserRole.ADMIN) { // Client admin: can only remove users (not other admins) diff --git a/libs/domains/identity/backend/feature-auth/src/lib/services/personal-access-token.service.spec.ts b/libs/domains/identity/backend/feature-auth/src/lib/services/personal-access-token.service.spec.ts new file mode 100644 index 000000000..b3c28df4a --- /dev/null +++ b/libs/domains/identity/backend/feature-auth/src/lib/services/personal-access-token.service.spec.ts @@ -0,0 +1,402 @@ +import { UserRole } from '@forepath/identity/backend'; +import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; +import * as bcrypt from 'bcrypt'; + +import { PersonalAccessTokenService } from './personal-access-token.service'; + +describe('PersonalAccessTokenService', () => { + const catalog = ['usage:write', 'webhooks:admin', 'invoices:read'] as const; + const mockTokensRepository = { + create: jest.fn(), + findByPrefix: jest.fn(), + findById: jest.fn(), + findActiveByUserId: jest.fn(), + findAllByUserId: jest.fn(), + save: jest.fn(), + touchLastUsedAtIfActive: jest.fn().mockResolvedValue(true), + }; + const mockUsersRepository = { + findByIdOrThrow: jest.fn(), + findById: jest.fn(), + }; + let service: PersonalAccessTokenService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new PersonalAccessTokenService(mockTokensRepository as any, mockUsersRepository as any, catalog); + }); + + it('filters admin-only scopes for non-admin catalog', () => { + expect(service.getCatalogForRole(UserRole.USER)).toEqual(['invoices:read']); + expect(service.getCatalogForRole(UserRole.ADMIN)).toEqual([...catalog]); + }); + + it('getCatalogForUser uses DB role', async () => { + mockUsersRepository.findByIdOrThrow.mockResolvedValue({ id: 'u1', role: UserRole.USER }); + + await expect(service.getCatalogForUser('u1')).resolves.toEqual(['invoices:read']); + }); + + it('creates a token with plaintext once', async () => { + mockUsersRepository.findByIdOrThrow.mockResolvedValue({ + id: 'u1', + passwordHash: 'hash', + role: UserRole.ADMIN, + }); + mockTokensRepository.create.mockImplementation(async (data: Record) => ({ + ...data, + id: 't1', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + revokedAt: null, + lastUsedAt: null, + expiresAt: null, + })); + + const result = await service.create('u1', { + name: 'CI', + scopes: ['usage:write'], + }); + + expect(result.token.startsWith('fp_pat_')).toBe(true); + expect(result.scopes).toEqual(['usage:write']); + expect(mockTokensRepository.create).toHaveBeenCalled(); + }); + + it('creates a token for keycloak-linked users without passwordHash', async () => { + mockUsersRepository.findByIdOrThrow.mockResolvedValue({ + id: 'u1', + passwordHash: null, + keycloakSub: 'kc-sub-1', + role: UserRole.ADMIN, + }); + mockTokensRepository.create.mockImplementation(async (data: Record) => ({ + ...data, + id: 't1', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + revokedAt: null, + lastUsedAt: null, + expiresAt: null, + })); + + const result = await service.create('u1', { + name: 'CI', + scopes: ['usage:write'], + }); + + expect(result.token.startsWith('fp_pat_')).toBe(true); + }); + + it('rejects create when account has neither passwordHash nor keycloakSub', async () => { + mockUsersRepository.findByIdOrThrow.mockResolvedValue({ + id: 'u1', + passwordHash: null, + keycloakSub: null, + role: UserRole.ADMIN, + }); + + await expect(service.create('u1', { name: 'x', scopes: ['usage:write'] })).rejects.toThrow(BadRequestException); + }); + + it('rejects unknown scopes', async () => { + mockUsersRepository.findByIdOrThrow.mockResolvedValue({ id: 'u1', passwordHash: 'hash', role: UserRole.ADMIN }); + + await expect(service.create('u1', { name: 'x', scopes: ['nope:write'] })).rejects.toThrow(BadRequestException); + }); + + it('rejects admin scopes when DB role is non-admin', async () => { + mockUsersRepository.findByIdOrThrow.mockResolvedValue({ id: 'u1', passwordHash: 'hash', role: UserRole.USER }); + + await expect(service.create('u1', { name: 'x', scopes: ['usage:write'] })).rejects.toThrow(ForbiddenException); + }); + + it('updates name and scopes for an owned active token', async () => { + mockUsersRepository.findByIdOrThrow.mockResolvedValue({ id: 'u1', role: UserRole.ADMIN }); + mockTokensRepository.findById.mockResolvedValue({ + id: 't1', + userId: 'u1', + name: 'Old', + scopes: ['invoices:read'], + revokedAt: null, + tokenPrefix: 'fp_pat_x', + expiresAt: null, + lastUsedAt: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + }); + mockTokensRepository.save.mockImplementation(async (entity) => entity); + + const result = await service.update('u1', 't1', { + name: ' Renamed ', + scopes: ['usage:write', 'invoices:read'], + }); + + expect(result.name).toBe('Renamed'); + expect(result.scopes).toEqual(['usage:write', 'invoices:read']); + expect(mockTokensRepository.save).toHaveBeenCalled(); + }); + + it('rejects update when token belongs to another user', async () => { + mockUsersRepository.findByIdOrThrow.mockResolvedValue({ id: 'u1', role: UserRole.ADMIN }); + mockTokensRepository.findById.mockResolvedValue({ + id: 't1', + userId: 'other', + revokedAt: null, + }); + + await expect(service.update('u1', 't1', { name: 'x', scopes: ['invoices:read'] })).rejects.toThrow( + 'Token not found', + ); + }); + + it('rejects update of revoked tokens', async () => { + mockUsersRepository.findByIdOrThrow.mockResolvedValue({ id: 'u1', role: UserRole.ADMIN }); + mockTokensRepository.findById.mockResolvedValue({ + id: 't1', + userId: 'u1', + revokedAt: new Date(), + }); + + await expect(service.update('u1', 't1', { name: 'x', scopes: ['invoices:read'] })).rejects.toThrow( + 'Token not found', + ); + }); + + it('rejects admin scopes on update when DB role is non-admin', async () => { + mockUsersRepository.findByIdOrThrow.mockResolvedValue({ id: 'u1', role: UserRole.USER }); + mockTokensRepository.findById.mockResolvedValue({ + id: 't1', + userId: 'u1', + name: 'CI', + scopes: ['invoices:read'], + revokedAt: null, + }); + + await expect(service.update('u1', 't1', { name: 'CI', scopes: ['usage:write'] })).rejects.toThrow( + ForbiddenException, + ); + }); + + it('verifies a valid token', async () => { + const plaintext = 'fp_pat_abcdefghijklmnopqr'; + const tokenHash = await bcrypt.hash(plaintext, 4); + + mockTokensRepository.findByPrefix.mockResolvedValue({ + id: 't1', + userId: 'u1', + tokenHash, + scopes: ['usage:write'], + revokedAt: null, + expiresAt: null, + }); + mockTokensRepository.touchLastUsedAtIfActive.mockResolvedValue(true); + mockUsersRepository.findById.mockResolvedValue({ + id: 'u1', + passwordHash: 'hash', + emailConfirmedAt: new Date(), + lockedAt: null, + role: UserRole.ADMIN, + }); + + const verified = await service.verifyToken(plaintext); + + expect(verified.patId).toBe('t1'); + expect(verified.scopes).toEqual(['usage:write']); + expect(mockTokensRepository.touchLastUsedAtIfActive).toHaveBeenCalled(); + }); + + it('strips admin-only scopes at verify when user is demoted', async () => { + const plaintext = 'fp_pat_abcdefghijklmnopqr'; + const tokenHash = await bcrypt.hash(plaintext, 4); + + mockTokensRepository.findByPrefix.mockResolvedValue({ + id: 't1', + userId: 'u1', + tokenHash, + scopes: ['usage:write', 'invoices:read'], + revokedAt: null, + expiresAt: null, + }); + mockTokensRepository.touchLastUsedAtIfActive.mockResolvedValue(true); + mockUsersRepository.findById.mockResolvedValue({ + id: 'u1', + passwordHash: 'hash', + emailConfirmedAt: new Date(), + lockedAt: null, + role: UserRole.USER, + }); + + const verified = await service.verifyToken(plaintext); + + expect(verified.scopes).toEqual(['invoices:read']); + }); + + it('rejects create with blank name after trim', async () => { + mockUsersRepository.findByIdOrThrow.mockResolvedValue({ id: 'u1', passwordHash: 'hash', role: UserRole.ADMIN }); + + await expect(service.create('u1', { name: ' ', scopes: ['usage:write'] })).rejects.toThrow(BadRequestException); + }); + + it('rejects create with past expiresAt', async () => { + mockUsersRepository.findByIdOrThrow.mockResolvedValue({ id: 'u1', passwordHash: 'hash', role: UserRole.ADMIN }); + + await expect( + service.create('u1', { + name: 'CI', + scopes: ['usage:write'], + expiresAt: new Date('2020-01-01T00:00:00.000Z'), + }), + ).rejects.toThrow(BadRequestException); + }); + + it('assertPatJwtActive rejects revoked tokens', async () => { + mockTokensRepository.findById.mockResolvedValue({ + id: 't1', + userId: 'u1', + scopes: ['usage:write'], + revokedAt: new Date(), + expiresAt: null, + }); + + await expect(service.assertPatJwtActive('t1', 'u1', ['usage:write'])).rejects.toThrow(UnauthorizedException); + }); + + it('assertPatJwtActive rejects when JWT scopes diverge from DB', async () => { + mockTokensRepository.findById.mockResolvedValue({ + id: 't1', + userId: 'u1', + scopes: ['invoices:read'], + revokedAt: null, + expiresAt: null, + lastUsedAt: null, + }); + mockUsersRepository.findById.mockResolvedValue({ + id: 'u1', + passwordHash: 'hash', + emailConfirmedAt: new Date(), + lockedAt: null, + role: UserRole.ADMIN, + }); + + await expect(service.assertPatJwtActive('t1', 'u1', ['usage:write'])).rejects.toThrow(UnauthorizedException); + }); + + it('assertPatJwtActive rejects when email is unconfirmed', async () => { + mockTokensRepository.findById.mockResolvedValue({ + id: 't1', + userId: 'u1', + scopes: ['usage:write'], + revokedAt: null, + expiresAt: null, + lastUsedAt: null, + }); + mockUsersRepository.findById.mockResolvedValue({ + id: 'u1', + passwordHash: 'hash', + emailConfirmedAt: null, + lockedAt: null, + role: UserRole.ADMIN, + }); + + await expect(service.assertPatJwtActive('t1', 'u1', ['usage:write'])).rejects.toThrow(UnauthorizedException); + }); + + it('assertPatJwtActive allows keycloak-linked users without passwordHash', async () => { + mockTokensRepository.findById.mockResolvedValue({ + id: 't1', + userId: 'u1', + scopes: ['usage:write'], + revokedAt: null, + expiresAt: null, + lastUsedAt: null, + }); + mockUsersRepository.findById.mockResolvedValue({ + id: 'u1', + passwordHash: null, + keycloakSub: 'kc-sub-1', + emailConfirmedAt: new Date(), + lockedAt: null, + role: UserRole.ADMIN, + }); + + await expect(service.assertPatJwtActive('t1', 'u1', ['usage:write'])).resolves.toEqual({ + scopes: ['usage:write'], + }); + }); + + it('assertPatJwtActive rejects when account has neither passwordHash nor keycloakSub', async () => { + mockTokensRepository.findById.mockResolvedValue({ + id: 't1', + userId: 'u1', + scopes: ['usage:write'], + revokedAt: null, + expiresAt: null, + lastUsedAt: null, + }); + mockUsersRepository.findById.mockResolvedValue({ + id: 'u1', + passwordHash: null, + keycloakSub: null, + emailConfirmedAt: new Date(), + lockedAt: null, + role: UserRole.ADMIN, + }); + + await expect(service.assertPatJwtActive('t1', 'u1', ['usage:write'])).rejects.toThrow(UnauthorizedException); + }); + + it('assertPatJwtActive touches lastUsedAt when stale', async () => { + const entity = { + id: 't1', + userId: 'u1', + scopes: ['usage:write'], + revokedAt: null, + expiresAt: null, + lastUsedAt: new Date(Date.now() - 10 * 60 * 1000), + }; + + mockTokensRepository.findById.mockResolvedValue(entity); + mockUsersRepository.findById.mockResolvedValue({ + id: 'u1', + passwordHash: 'hash', + emailConfirmedAt: new Date(), + lockedAt: null, + role: UserRole.ADMIN, + }); + + await expect(service.assertPatJwtActive('t1', 'u1', ['usage:write'])).resolves.toEqual({ + scopes: ['usage:write'], + }); + expect(mockTokensRepository.touchLastUsedAtIfActive).toHaveBeenCalledWith('t1', expect.any(Date)); + expect(mockTokensRepository.save).not.toHaveBeenCalled(); + }); + + it('assertPatJwtActive skips lastUsedAt touch when recent', async () => { + const entity = { + id: 't1', + userId: 'u1', + scopes: ['usage:write'], + revokedAt: null, + expiresAt: null, + lastUsedAt: new Date(), + }; + + mockTokensRepository.findById.mockResolvedValue(entity); + mockUsersRepository.findById.mockResolvedValue({ + id: 'u1', + passwordHash: 'hash', + emailConfirmedAt: new Date(), + lockedAt: null, + role: UserRole.ADMIN, + }); + + await expect(service.assertPatJwtActive('t1', 'u1', ['usage:write'])).resolves.toEqual({ + scopes: ['usage:write'], + }); + expect(mockTokensRepository.touchLastUsedAtIfActive).not.toHaveBeenCalled(); + }); + + it('rejects invalid token', async () => { + mockTokensRepository.findByPrefix.mockResolvedValue(null); + + await expect(service.verifyToken('fp_pat_notarealtokenvaluexx')).rejects.toThrow(UnauthorizedException); + }); +}); diff --git a/libs/domains/identity/backend/feature-auth/src/lib/services/personal-access-token.service.ts b/libs/domains/identity/backend/feature-auth/src/lib/services/personal-access-token.service.ts new file mode 100644 index 000000000..2abb6b5f6 --- /dev/null +++ b/libs/domains/identity/backend/feature-auth/src/lib/services/personal-access-token.service.ts @@ -0,0 +1,341 @@ +import { randomBytes } from 'node:crypto'; + +import { UserEntity, UserPersonalAccessTokenEntity, UserRole } from '@forepath/identity/backend'; +import { + BadRequestException, + ForbiddenException, + Inject, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import * as bcrypt from 'bcrypt'; + +import { + ADMIN_ONLY_PAT_SCOPES, + DUMMY_PAT_BCRYPT_HASH, + IDENTITY_PAT_SCOPE_CATALOG, + PAT_LOOKUP_PREFIX_LENGTH, + PAT_TOKEN_PREFIX, +} from '../constants/pat.constants'; +import { PersonalAccessTokensRepository } from '../repositories/personal-access-tokens.repository'; +import { UsersRepository } from '../repositories/users.repository'; + +const BCRYPT_ROUNDS = 12; +/** Avoid writing lastUsedAt on every authenticated PAT request. */ +const LAST_USED_TOUCH_INTERVAL_MS = 5 * 60 * 1000; + +export interface CreatePatInput { + name: string; + scopes: string[]; + expiresAt?: Date | null; +} + +export interface UpdatePatInput { + name: string; + scopes: string[]; +} + +export interface PatListItem { + id: string; + name: string; + tokenPrefix: string; + scopes: string[]; + expiresAt: Date | null; + revokedAt: Date | null; + lastUsedAt: Date | null; + createdAt: Date; +} + +export interface CreatePatResult extends PatListItem { + token: string; +} + +export interface VerifiedPat { + user: UserEntity; + scopes: string[]; + patId: string; +} + +@Injectable() +export class PersonalAccessTokenService { + constructor( + private readonly tokensRepository: PersonalAccessTokensRepository, + private readonly usersRepository: UsersRepository, + @Inject(IDENTITY_PAT_SCOPE_CATALOG) private readonly scopeCatalog: readonly string[], + ) {} + + getCatalogForRole(role: UserRole): string[] { + const catalog = [...this.scopeCatalog]; + + if (role === UserRole.ADMIN) { + return catalog; + } + + return catalog.filter((scope) => !ADMIN_ONLY_PAT_SCOPES.has(scope)); + } + + /** Scope catalog for the user's current DB role (not JWT role). */ + async getCatalogForUser(userId: string): Promise { + const user = await this.usersRepository.findByIdOrThrow(userId); + + return this.getCatalogForRole(user.role); + } + + async listForUser(userId: string): Promise { + const tokens = await this.tokensRepository.findActiveByUserId(userId); + + return tokens.map((token) => this.toListItem(token)); + } + + async listForUserAdmin(userId: string): Promise { + const user = await this.usersRepository.findById(userId); + + if (!user) { + throw new NotFoundException('User not found'); + } + + const tokens = await this.tokensRepository.findAllByUserId(userId); + + return tokens.map((token) => this.toListItem(token)); + } + + async create(userId: string, input: CreatePatInput): Promise { + const user = await this.usersRepository.findByIdOrThrow(userId); + + if (!this.isPatEligibleAccount(user)) { + throw new BadRequestException('Personal access tokens are not available for this account.'); + } + + const name = input.name.trim(); + + if (!name) { + throw new BadRequestException('Name is required'); + } + + if (input.expiresAt && input.expiresAt.getTime() <= Date.now()) { + throw new BadRequestException('Expiration must be in the future'); + } + + // Grants follow DB role so demotion takes effect without waiting for JWT expiry. + const scopes = this.normalizeAndValidateScopes(input.scopes, user.role); + const plaintext = this.generatePlaintextToken(); + const tokenPrefix = plaintext.slice(0, PAT_LOOKUP_PREFIX_LENGTH); + const tokenHash = await bcrypt.hash(plaintext, BCRYPT_ROUNDS); + const entity = await this.tokensRepository.create({ + userId, + name, + tokenPrefix, + tokenHash, + scopes, + expiresAt: input.expiresAt ?? null, + }); + + return { + ...this.toListItem(entity), + token: plaintext, + }; + } + + async update(userId: string, tokenId: string, input: UpdatePatInput): Promise { + const user = await this.usersRepository.findByIdOrThrow(userId); + const entity = await this.tokensRepository.findById(tokenId); + + if (!entity || entity.userId !== userId || entity.revokedAt) { + throw new NotFoundException('Token not found'); + } + + const name = input.name.trim(); + + if (!name) { + throw new BadRequestException('Name is required'); + } + + const scopes = this.normalizeAndValidateScopes(input.scopes, user.role); + + entity.name = name; + entity.scopes = scopes; + + const saved = await this.tokensRepository.save(entity); + + return this.toListItem(saved); + } + + async revoke(userId: string, tokenId: string): Promise { + const entity = await this.tokensRepository.findById(tokenId); + + if (!entity || entity.userId !== userId) { + throw new NotFoundException('Token not found'); + } + + if (entity.revokedAt) { + return; + } + + entity.revokedAt = new Date(); + await this.tokensRepository.save(entity); + } + + async revokeAsAdmin(userId: string, tokenId: string): Promise { + const entity = await this.tokensRepository.findById(tokenId); + + if (!entity || entity.userId !== userId) { + throw new NotFoundException('Token not found'); + } + + if (entity.revokedAt) { + return; + } + + entity.revokedAt = new Date(); + await this.tokensRepository.save(entity); + } + + async verifyToken(plaintext: string): Promise { + if (!plaintext.startsWith(PAT_TOKEN_PREFIX) || plaintext.length < PAT_LOOKUP_PREFIX_LENGTH) { + throw new UnauthorizedException('Invalid token'); + } + + const tokenPrefix = plaintext.slice(0, PAT_LOOKUP_PREFIX_LENGTH); + const entity = await this.tokensRepository.findByPrefix(tokenPrefix); + // Always bcrypt-compare to reduce timing oracles when prefix is unknown. + const hashToCompare = entity?.tokenHash ?? DUMMY_PAT_BCRYPT_HASH; + const valid = await bcrypt.compare(plaintext, hashToCompare); + + if (!entity || !valid || entity.revokedAt) { + throw new UnauthorizedException('Invalid token'); + } + + if (entity.expiresAt && entity.expiresAt.getTime() < Date.now()) { + throw new UnauthorizedException('Invalid token'); + } + + const user = await this.usersRepository.findById(entity.userId); + + if (!user || !this.isPatEligibleAccount(user)) { + throw new UnauthorizedException('Invalid token'); + } + + if (!user.emailConfirmedAt) { + throw new UnauthorizedException('Invalid token'); + } + + if (user.lockedAt) { + throw new UnauthorizedException('Invalid token'); + } + + const scopes = this.filterScopesForRole([...entity.scopes], user.role); + + if (scopes.length === 0) { + throw new UnauthorizedException('Invalid token'); + } + + entity.lastUsedAt = new Date(); + await this.tokensRepository.touchLastUsedAtIfActive(entity.id, entity.lastUsedAt); + + return { + user, + scopes, + patId: entity.id, + }; + } + + /** + * Live validation for an exchanged PAT JWT: reject revoked/expired tokens and + * refresh scopes from the DB (scope updates and role demotion take effect immediately). + */ + async assertPatJwtActive( + patId: string, + userId: string, + jwtScopes: string[] | undefined, + ): Promise<{ scopes: string[] }> { + const entity = await this.tokensRepository.findById(patId); + + if (!entity || entity.userId !== userId || entity.revokedAt) { + throw new UnauthorizedException('Session is no longer valid.'); + } + + if (entity.expiresAt && entity.expiresAt.getTime() < Date.now()) { + throw new UnauthorizedException('Session is no longer valid.'); + } + + const user = await this.usersRepository.findById(userId); + + // Align with verifyToken: password or Keycloak-linked accounts, confirmed, not locked. + if (!user || !this.isPatEligibleAccount(user) || !user.emailConfirmedAt || user.lockedAt) { + throw new UnauthorizedException('Session is no longer valid.'); + } + + const scopes = this.filterScopesForRole([...entity.scopes], user.role); + + if (scopes.length === 0) { + throw new UnauthorizedException('Session is no longer valid.'); + } + + const jwtSet = new Set(jwtScopes ?? []); + const scopesMatch = scopes.length === jwtSet.size && scopes.every((scope) => jwtSet.has(scope)); + + if (!scopesMatch) { + throw new UnauthorizedException('Session is no longer valid.'); + } + + const now = Date.now(); + const lastUsedMs = entity.lastUsedAt?.getTime() ?? 0; + + if (now - lastUsedMs >= LAST_USED_TOUCH_INTERVAL_MS) { + await this.tokensRepository.touchLastUsedAtIfActive(entity.id, new Date(now)); + } + + return { scopes }; + } + + private isPatEligibleAccount(user: UserEntity): boolean { + return Boolean(user.passwordHash || user.keycloakSub); + } + + private filterScopesForRole(scopes: string[], role: UserRole): string[] { + const grantable = new Set(this.getCatalogForRole(role)); + + return scopes.filter((scope) => grantable.has(scope)); + } + + private normalizeAndValidateScopes(scopes: string[], actorRole: UserRole): string[] { + if (!Array.isArray(scopes) || scopes.length === 0) { + throw new BadRequestException('At least one scope is required'); + } + + const unique = [...new Set(scopes.map((scope) => scope.trim()).filter(Boolean))]; + const catalog = new Set(this.scopeCatalog); + const unknown = unique.filter((scope) => !catalog.has(scope)); + + if (unknown.length > 0) { + throw new BadRequestException(`Unsupported scopes: ${unknown.join(', ')}`); + } + + const grantable = new Set(this.getCatalogForRole(actorRole)); + const forbidden = unique.filter((scope) => !grantable.has(scope)); + + if (forbidden.length > 0) { + throw new ForbiddenException(`Cannot grant scopes: ${forbidden.join(', ')}`); + } + + return unique; + } + + private generatePlaintextToken(): string { + return `${PAT_TOKEN_PREFIX}${randomBytes(32).toString('base64url')}`; + } + + private toListItem(entity: UserPersonalAccessTokenEntity): PatListItem { + return { + id: entity.id, + name: entity.name, + tokenPrefix: entity.tokenPrefix, + scopes: [...entity.scopes], + expiresAt: entity.expiresAt ?? null, + revokedAt: entity.revokedAt ?? null, + lastUsedAt: entity.lastUsedAt ?? null, + createdAt: entity.createdAt, + }; + } +} diff --git a/libs/domains/identity/backend/feature-auth/src/lib/services/socket-auth.service.spec.ts b/libs/domains/identity/backend/feature-auth/src/lib/services/socket-auth.service.spec.ts index 146955afe..aa748496b 100644 --- a/libs/domains/identity/backend/feature-auth/src/lib/services/socket-auth.service.spec.ts +++ b/libs/domains/identity/backend/feature-auth/src/lib/services/socket-auth.service.spec.ts @@ -97,4 +97,33 @@ describe('SocketAuthService', () => { }), ); }); + + it('rejects Keycloak-mode PAT JWTs before OIDC validation', async () => { + process.env.AUTHENTICATION_METHOD = 'keycloak'; + const keycloak = { + grantManager: { + createGrant: jest.fn(), + validateAccessToken: jest.fn(), + validateToken: jest.fn(), + }, + }; + + jwtService.verifyAsync.mockResolvedValue({ + sub: 'user-1', + amr: ['pat'], + patId: 't1', + scopes: ['clients:read'], + }); + const keycloakService = new SocketAuthService( + keycloak as never, + { tokenValidation: 'ONLINE' } as never, + jwtService as unknown as JwtService, + usersRepository as unknown as UsersRepository, + revokedUserTokensRepository as unknown as RevokedUserTokensRepository, + ); + const result = await keycloakService.validateAndGetUser('Bearer pat.jwt.token'); + + expect(result).toBeNull(); + expect(keycloak.grantManager.createGrant).not.toHaveBeenCalled(); + }); }); diff --git a/libs/domains/identity/backend/feature-auth/src/lib/services/socket-auth.service.ts b/libs/domains/identity/backend/feature-auth/src/lib/services/socket-auth.service.ts index 371ad640a..67b1e4049 100644 --- a/libs/domains/identity/backend/feature-auth/src/lib/services/socket-auth.service.ts +++ b/libs/domains/identity/backend/feature-auth/src/lib/services/socket-auth.service.ts @@ -91,6 +91,19 @@ export class SocketAuthService { } private async validateKeycloakToken(token: string, tenantId?: string): Promise { + // Reject app-signed PAT JWTs explicitly (same policy as users-mode sockets). + if (this.jwtService) { + try { + const appPayload = await this.jwtService.verifyAsync(token); + + if ((appPayload.amr ?? []).includes('pat')) { + return null; + } + } catch { + // Not an app-signed JWT — continue with Keycloak OIDC validation. + } + } + // Use exact same validation logic as HTTP AuthGuard from nest-keycloak-connect const tokenValidation = this.keycloakOpts?.tokenValidation || TokenValidation.ONLINE; const gm = this.keycloak!.grantManager; @@ -180,6 +193,12 @@ export class SocketAuthService { private async validateUsersToken(token: string, tenantId?: string): Promise { try { const payload = await this.jwtService!.verifyAsync(token); + + // Interactive console sockets reject machine (PAT) sessions. + if ((payload.amr ?? ['pwd']).includes('pat')) { + return null; + } + const entity = await this.usersRepository.findById(payload.sub); if (!entity || entity.lockedAt || !this.userMatchesTenant(entity, tenantId)) { @@ -198,15 +217,18 @@ export class SocketAuthService { const roles = payload.roles ?? ['user']; const isAdmin = roles.includes('admin'); + const amr = payload.amr ?? ['pwd']; return { userId: payload.sub, userRole: isAdmin ? UserRole.ADMIN : UserRole.USER, isApiKeyAuth: false, + amr, user: { id: payload.sub, email: payload.email, roles, + amr, }, }; } catch { diff --git a/libs/domains/identity/backend/feature-auth/src/lib/services/users.service.spec.ts b/libs/domains/identity/backend/feature-auth/src/lib/services/users.service.spec.ts index 287e3d9f9..6eb9e0489 100644 --- a/libs/domains/identity/backend/feature-auth/src/lib/services/users.service.spec.ts +++ b/libs/domains/identity/backend/feature-auth/src/lib/services/users.service.spec.ts @@ -129,6 +129,28 @@ describe('UsersService', () => { expect(mockUsersRepository.incrementTokenVersion).toHaveBeenCalledWith('user-1'); }); + it('increments token version when role changes', async () => { + mockUsersRepository.findByIdForTenant.mockResolvedValue({ + id: 'user-1', + email: 'user@example.com', + role: UserRole.ADMIN, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }); + mockUsersRepository.update.mockResolvedValue({ + id: 'user-1', + email: 'user@example.com', + role: UserRole.USER, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-02T00:00:00.000Z'), + }); + mockUsersRepository.incrementTokenVersion.mockResolvedValue(1); + + await service.update('user-1', { role: UserRole.USER }); + + expect(mockUsersRepository.incrementTokenVersion).toHaveBeenCalledWith('user-1'); + }); + it('publishes confirmation email when creating a non-first user', async () => { mockUsersRepository.findByEmail.mockResolvedValue(null); mockUsersRepository.create.mockResolvedValue({ diff --git a/libs/domains/identity/backend/feature-auth/src/lib/services/users.service.ts b/libs/domains/identity/backend/feature-auth/src/lib/services/users.service.ts index 3cc51dd42..6b3323869 100644 --- a/libs/domains/identity/backend/feature-auth/src/lib/services/users.service.ts +++ b/libs/domains/identity/backend/feature-auth/src/lib/services/users.service.ts @@ -156,7 +156,8 @@ export class UsersService { const updated = await this.usersRepository.update(id, updateData); - if (dto.password) { + // Password or role changes invalidate outstanding JWTs via tokenVersion. + if (dto.password || (dto.role !== undefined && dto.role !== user.role)) { await this.usersRepository.incrementTokenVersion(id); } diff --git a/libs/domains/identity/backend/feature-auth/src/lib/utils/get-keycloak-pat-auth-guards.spec.ts b/libs/domains/identity/backend/feature-auth/src/lib/utils/get-keycloak-pat-auth-guards.spec.ts new file mode 100644 index 000000000..de4241765 --- /dev/null +++ b/libs/domains/identity/backend/feature-auth/src/lib/utils/get-keycloak-pat-auth-guards.spec.ts @@ -0,0 +1,51 @@ +import { APP_GUARD } from '@nestjs/core'; + +import { KeycloakAuthGuard } from '../guards/keycloak-auth.guard'; +import { KeycloakRolesGuard } from '../guards/keycloak-roles.guard'; +import { PatBearerAuthGuard } from '../guards/pat-bearer-auth.guard'; +import { PatScopesGuard } from '../guards/pat-scopes.guard'; +import { UsersRolesGuard } from '../guards/users-roles.guard'; + +import { getKeycloakPatAuthGuards } from './get-keycloak-pat-auth-guards'; + +describe('getKeycloakPatAuthGuards', () => { + let originalAuthMethod: string | undefined; + + beforeEach(() => { + originalAuthMethod = process.env.AUTHENTICATION_METHOD; + }); + + afterEach(() => { + if (originalAuthMethod !== undefined) { + process.env.AUTHENTICATION_METHOD = originalAuthMethod; + } else { + delete process.env.AUTHENTICATION_METHOD; + } + }); + + it('registers Keycloak+PAT chain in PatBearer → nest → sync → roles → scopes order', () => { + process.env.AUTHENTICATION_METHOD = 'keycloak'; + + const guards = getKeycloakPatAuthGuards(); + const classes = guards.map((g) => g.useClass); + + expect(guards.every((g) => g.provide === APP_GUARD)).toBe(true); + expect(classes[0]).toBe(PatBearerAuthGuard); + expect(classes.indexOf(KeycloakAuthGuard)).toBeGreaterThan(classes.indexOf(PatBearerAuthGuard)); + expect(classes.indexOf(KeycloakRolesGuard)).toBeGreaterThan(classes.indexOf(KeycloakAuthGuard)); + expect(classes.indexOf(UsersRolesGuard)).toBeGreaterThan(classes.indexOf(KeycloakRolesGuard)); + expect(classes.indexOf(PatScopesGuard)).toBeGreaterThan(classes.indexOf(UsersRolesGuard)); + expect(classes.filter((c) => c === PatBearerAuthGuard)).toHaveLength(1); + expect(classes.filter((c) => c === KeycloakAuthGuard)).toHaveLength(1); + expect(classes.filter((c) => c === PatScopesGuard)).toHaveLength(1); + }); + + it('does not register Keycloak PAT chain when not in keycloak mode', () => { + process.env.AUTHENTICATION_METHOD = 'users'; + + const classes = getKeycloakPatAuthGuards().map((g) => g.useClass); + + expect(classes).not.toContain(PatBearerAuthGuard); + expect(classes).not.toContain(KeycloakAuthGuard); + }); +}); diff --git a/libs/domains/identity/backend/feature-auth/src/lib/utils/get-keycloak-pat-auth-guards.ts b/libs/domains/identity/backend/feature-auth/src/lib/utils/get-keycloak-pat-auth-guards.ts new file mode 100644 index 000000000..591088dd6 --- /dev/null +++ b/libs/domains/identity/backend/feature-auth/src/lib/utils/get-keycloak-pat-auth-guards.ts @@ -0,0 +1,37 @@ +import { getAuthenticationMethod, getHybridAuthGuards } from '@forepath/identity/backend/util-auth'; +import { CanActivate } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; + +import { KeycloakAuthGuard } from '../guards/keycloak-auth.guard'; +import { KeycloakRolesGuard } from '../guards/keycloak-roles.guard'; +import { PatBearerAuthGuard } from '../guards/pat-bearer-auth.guard'; +import { PatScopesGuard } from '../guards/pat-scopes.guard'; +import { UsersRolesGuard } from '../guards/users-roles.guard'; + +type AppGuardProvider = { provide: typeof APP_GUARD; useClass: new (...args: unknown[]) => CanActivate }; + +/** + * Single APP_GUARD chain for Keycloak + PAT hybrid. + * + * Order (must stay explicit — do not also register these as APP_GUARD in feature modules): + * 1. PatBearerAuthGuard — accept app-signed `amr: pat` JWTs + * 2. Hybrid + nest-keycloak Auth/Resource/Role (skip when `patAuthenticated`) + * 3. KeycloakAuthGuard — sync OIDC user → local `users` row + * 4. KeycloakRolesGuard — `@KeycloakRoles` for interactive OIDC + * 5. UsersRolesGuard — `@UsersRoles` for PAT JWTs (and users-mode) + * 6. PatScopesGuard — fail-closed scopes / interactive-session-only routes + */ +export function getKeycloakPatAuthGuards(): AppGuardProvider[] { + if (getAuthenticationMethod() !== 'keycloak') { + return getHybridAuthGuards() as AppGuardProvider[]; + } + + return [ + { provide: APP_GUARD, useClass: PatBearerAuthGuard }, + ...(getHybridAuthGuards() as AppGuardProvider[]), + { provide: APP_GUARD, useClass: KeycloakAuthGuard }, + { provide: APP_GUARD, useClass: KeycloakRolesGuard }, + { provide: APP_GUARD, useClass: UsersRolesGuard }, + { provide: APP_GUARD, useClass: PatScopesGuard }, + ]; +} diff --git a/libs/domains/identity/backend/feature-auth/src/lib/utils/users-jwt-session.util.ts b/libs/domains/identity/backend/feature-auth/src/lib/utils/users-jwt-session.util.ts index 3ca398860..54057c578 100644 --- a/libs/domains/identity/backend/feature-auth/src/lib/utils/users-jwt-session.util.ts +++ b/libs/domains/identity/backend/feature-auth/src/lib/utils/users-jwt-session.util.ts @@ -7,6 +7,12 @@ export interface UsersJwtSessionPayload { sub: string; email: string; roles: string[]; + /** Authentication method reference: `pwd` (console) or `pat` (machine). */ + amr?: string[]; + /** Capability scopes present only for PAT-issued JWTs. */ + scopes?: string[]; + /** Personal access token id for live revoke/scope checks (`amr: pat` only). */ + patId?: string; tv?: number; jti?: string; exp?: number; diff --git a/libs/domains/identity/backend/util-auth/src/index.ts b/libs/domains/identity/backend/util-auth/src/index.ts index 423ad7cea..542abee54 100644 --- a/libs/domains/identity/backend/util-auth/src/index.ts +++ b/libs/domains/identity/backend/util-auth/src/index.ts @@ -20,11 +20,13 @@ export * from './lib/entities/authentication-type.enum'; export * from './lib/entities/client-user.entity'; export * from './lib/entities/client.entity.types'; export * from './lib/entities/revoked-user-token.entity'; +export * from './lib/entities/user-personal-access-token.entity'; export * from './lib/entities/user.entity'; export * from './lib/password.service'; export * from './lib/statistics.interface'; export * from './lib/notification.interface'; export * from './lib/identity-notification.events'; +export * from './lib/identity-pat.scopes'; export * from './lib/email-dispatcher.interface'; export * from './lib/token.utils'; @@ -34,3 +36,4 @@ export * from './lib/migrations/1765000000000_CreateUsersTable'; export * from './lib/migrations/1770550000000_AddTenantIdToUsers'; export * from './lib/migrations/1775000000000_AddUserTokenVersion'; export * from './lib/migrations/1775000000001_CreateRevokedUserTokensTable'; +export * from './lib/migrations/1776100000000_CreateUserPersonalAccessTokensTable'; diff --git a/libs/domains/identity/backend/util-auth/src/lib/bull-board-keycloak.guards.spec.ts b/libs/domains/identity/backend/util-auth/src/lib/bull-board-keycloak.guards.spec.ts index f9bced55e..e26d22e38 100644 --- a/libs/domains/identity/backend/util-auth/src/lib/bull-board-keycloak.guards.spec.ts +++ b/libs/domains/identity/backend/util-auth/src/lib/bull-board-keycloak.guards.spec.ts @@ -24,10 +24,10 @@ describe('BullBoardSkipping Keycloak guards', () => { jest.restoreAllMocks(); }); - function createContext(path: string): ExecutionContext { + function createContext(path: string, extras: Record = {}): ExecutionContext { return { switchToHttp: () => ({ - getRequest: () => ({ originalUrl: path, url: path }), + getRequest: () => ({ originalUrl: path, url: path, ...extras }), }), } as ExecutionContext; } @@ -43,6 +43,14 @@ describe('BullBoardSkipping Keycloak guards', () => { expect(superCanActivate).toHaveBeenCalledTimes(1); }); + it('BullBoardSkippingAuthGuard skips when patAuthenticated', async () => { + const guard = Object.create(BullBoardSkippingAuthGuard.prototype) as BullBoardSkippingAuthGuard; + const superCanActivate = jest.spyOn(KeycloakAuthGuard.prototype, 'canActivate').mockResolvedValue(false); + + await expect(guard.canActivate(createContext('/api/clients', { patAuthenticated: true }))).resolves.toBe(true); + expect(superCanActivate).not.toHaveBeenCalled(); + }); + it('BullBoardSkippingResourceGuard skips Bull Board paths', async () => { const guard = Object.create(BullBoardSkippingResourceGuard.prototype) as BullBoardSkippingResourceGuard; const superCanActivate = jest.spyOn(KeycloakResourceGuard.prototype, 'canActivate').mockResolvedValue(false); @@ -54,6 +62,14 @@ describe('BullBoardSkipping Keycloak guards', () => { expect(superCanActivate).toHaveBeenCalledTimes(1); }); + it('BullBoardSkippingResourceGuard skips when patAuthenticated', async () => { + const guard = Object.create(BullBoardSkippingResourceGuard.prototype) as BullBoardSkippingResourceGuard; + const superCanActivate = jest.spyOn(KeycloakResourceGuard.prototype, 'canActivate').mockResolvedValue(false); + + await expect(guard.canActivate(createContext('/api/clients', { patAuthenticated: true }))).resolves.toBe(true); + expect(superCanActivate).not.toHaveBeenCalled(); + }); + it('BullBoardSkippingRoleGuard skips Bull Board paths', async () => { const guard = Object.create(BullBoardSkippingRoleGuard.prototype) as BullBoardSkippingRoleGuard; const superCanActivate = jest.spyOn(KeycloakRoleGuard.prototype, 'canActivate').mockResolvedValue(false); @@ -64,4 +80,12 @@ describe('BullBoardSkipping Keycloak guards', () => { await expect(guard.canActivate(createContext('/api/clients'))).resolves.toBe(false); expect(superCanActivate).toHaveBeenCalledTimes(1); }); + + it('BullBoardSkippingRoleGuard skips when patAuthenticated', async () => { + const guard = Object.create(BullBoardSkippingRoleGuard.prototype) as BullBoardSkippingRoleGuard; + const superCanActivate = jest.spyOn(KeycloakRoleGuard.prototype, 'canActivate').mockResolvedValue(false); + + await expect(guard.canActivate(createContext('/api/clients', { patAuthenticated: true }))).resolves.toBe(true); + expect(superCanActivate).not.toHaveBeenCalled(); + }); }); diff --git a/libs/domains/identity/backend/util-auth/src/lib/bull-board-keycloak.guards.ts b/libs/domains/identity/backend/util-auth/src/lib/bull-board-keycloak.guards.ts index 3cd61068c..2e1202499 100644 --- a/libs/domains/identity/backend/util-auth/src/lib/bull-board-keycloak.guards.ts +++ b/libs/domains/identity/backend/util-auth/src/lib/bull-board-keycloak.guards.ts @@ -12,11 +12,21 @@ function shouldSkipForBullBoard(context: ExecutionContext): boolean { return isBullBoardRequestPath(getHttpRequestPath(context)); } -/** Keycloak AuthGuard that skips Bull Board routes (HTTP Basic auth on the board). */ +function isPatAuthenticated(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest<{ patAuthenticated?: boolean }>(); + + return request.patAuthenticated === true; +} + +function shouldSkipKeycloakGuard(context: ExecutionContext): boolean { + return shouldSkipForBullBoard(context) || isPatAuthenticated(context); +} + +/** Keycloak AuthGuard that skips Bull Board routes and app-signed PAT JWTs. */ @Injectable() export class BullBoardSkippingAuthGuard extends KeycloakAuthGuard { canActivate(context: ExecutionContext): Promise { - if (shouldSkipForBullBoard(context)) { + if (shouldSkipKeycloakGuard(context)) { return Promise.resolve(true); } @@ -24,11 +34,11 @@ export class BullBoardSkippingAuthGuard extends KeycloakAuthGuard { } } -/** Keycloak ResourceGuard that skips Bull Board routes. */ +/** Keycloak ResourceGuard that skips Bull Board routes and app-signed PAT JWTs. */ @Injectable() export class BullBoardSkippingResourceGuard extends KeycloakResourceGuard { canActivate(context: ExecutionContext): Promise { - if (shouldSkipForBullBoard(context)) { + if (shouldSkipKeycloakGuard(context)) { return Promise.resolve(true); } @@ -36,11 +46,11 @@ export class BullBoardSkippingResourceGuard extends KeycloakResourceGuard { } } -/** Keycloak RoleGuard that skips Bull Board routes. */ +/** Keycloak RoleGuard that skips Bull Board routes and app-signed PAT JWTs. */ @Injectable() export class BullBoardSkippingRoleGuard extends KeycloakRoleGuard { canActivate(context: ExecutionContext): Promise { - if (shouldSkipForBullBoard(context)) { + if (shouldSkipKeycloakGuard(context)) { return Promise.resolve(true); } diff --git a/libs/domains/identity/backend/util-auth/src/lib/client-access.utils.spec.ts b/libs/domains/identity/backend/util-auth/src/lib/client-access.utils.spec.ts index b604fdc66..58ca0a96a 100644 --- a/libs/domains/identity/backend/util-auth/src/lib/client-access.utils.spec.ts +++ b/libs/domains/identity/backend/util-auth/src/lib/client-access.utils.spec.ts @@ -1,7 +1,9 @@ import { ForbiddenException } from '@nestjs/common'; import { + assertPatScopes, assertWorkspaceManagementAccessForUser, + buildRequestFromSocketUser, canManageWorkspaceConfiguration, checkClientAccess, ensureClientAccess, @@ -58,6 +60,21 @@ describe('client-access.utils', () => { expect(result.userRole).toBe(UserRole.ADMIN); }); + it('should include amr and scopes from the request user', () => { + const req = { + apiKeyAuthenticated: false, + user: { id: 'user-1', roles: ['user'], amr: ['pat'], scopes: ['usage:write'] }, + } as RequestWithUser; + + expect(getUserFromRequest(req)).toEqual({ + userId: 'user-1', + userRole: UserRole.USER, + isApiKeyAuth: false, + amr: ['pat'], + scopes: ['usage:write'], + }); + }); + it('should return isApiKeyAuth false when no user', () => { const req = {} as RequestWithUser; const result = getUserFromRequest(req); @@ -66,6 +83,75 @@ describe('client-access.utils', () => { }); }); + describe('buildRequestFromSocketUser', () => { + it('builds a request from socket user payload and amr', () => { + const request = buildRequestFromSocketUser({ + userId: 'user-1', + isApiKeyAuth: false, + amr: ['pwd'], + user: { id: 'user-1', email: 'a@b.c', roles: ['user'], amr: ['pwd'] }, + }); + + expect(request.apiKeyAuthenticated).toBe(false); + expect(request.user).toEqual({ + id: 'user-1', + email: 'a@b.c', + roles: ['user'], + amr: ['pwd'], + }); + }); + + it('falls back to userId when socket.user is absent', () => { + const request = buildRequestFromSocketUser({ + userId: 'user-2', + isApiKeyAuth: false, + amr: ['pat'], + }); + + expect(request.user).toEqual({ id: 'user-2', roles: [], amr: ['pat'] }); + }); + }); + + describe('assertPatScopes', () => { + it('no-ops for password and API-key sessions', () => { + expect(() => + assertPatScopes({ userId: 'u1', userRole: UserRole.USER, isApiKeyAuth: false, amr: ['pwd'] }, 'usage:write'), + ).not.toThrow(); + expect(() => assertPatScopes({ isApiKeyAuth: true }, 'usage:write')).not.toThrow(); + }); + + it('allows PAT sessions that include required scopes', () => { + expect(() => + assertPatScopes( + { + userId: 'u1', + userRole: UserRole.USER, + isApiKeyAuth: false, + amr: ['pat'], + scopes: ['usage:write', 'tickets:read'], + }, + 'usage:write', + ), + ).not.toThrow(); + }); + + it('throws when PAT session is missing required scopes', () => { + expect(() => + assertPatScopes( + { + userId: 'u1', + userRole: UserRole.USER, + isApiKeyAuth: false, + amr: ['pat'], + scopes: ['tickets:read'], + }, + 'usage:write', + 'webhooks:admin', + ), + ).toThrow(/Insufficient token scope\. Missing: usage:write, webhooks:admin/); + }); + }); + describe('checkClientAccess', () => { it('should grant access for API key auth', async () => { const result = await checkClientAccess( @@ -108,6 +194,23 @@ describe('client-access.utils', () => { expect(mockClientsRepository.findById).not.toHaveBeenCalled(); }); + it('should not grant global-admin bypass for PAT sessions', async () => { + mockClientsRepository.findById.mockResolvedValue(null); + + const result = await checkClientAccess( + mockClientsRepository as any, + mockClientUsersRepository as any, + 'client-1', + 'user-1', + UserRole.ADMIN, + false, + { amr: ['pat'] }, + ); + + expect(result).toEqual({ hasAccess: false, isClientCreator: false }); + expect(mockClientsRepository.findById).toHaveBeenCalled(); + }); + it('should grant access when user is client creator', async () => { mockClientsRepository.findById.mockResolvedValue({ id: 'client-1', userId: 'user-1' }); mockClientUsersRepository.findUserClientAccess.mockResolvedValue(null); @@ -225,6 +328,18 @@ describe('client-access.utils', () => { expect(canManageWorkspaceConfiguration(userInfo, { hasAccess: true, isClientCreator: false })).toBe(true); }); + it('returns false for PAT global admin without workspace membership', () => { + const userInfo: UserInfoFromRequest = { + userId: 'u1', + userRole: UserRole.ADMIN, + isApiKeyAuth: false, + amr: ['pat'], + scopes: ['usage:write'], + }; + + expect(canManageWorkspaceConfiguration(userInfo, { hasAccess: false, isClientCreator: false })).toBe(false); + }); + it('returns true for workspace creator', () => { const userInfo: UserInfoFromRequest = { userId: 'u1', diff --git a/libs/domains/identity/backend/util-auth/src/lib/client-access.utils.ts b/libs/domains/identity/backend/util-auth/src/lib/client-access.utils.ts index 9a0eab5b8..61900c722 100644 --- a/libs/domains/identity/backend/util-auth/src/lib/client-access.utils.ts +++ b/libs/domains/identity/backend/util-auth/src/lib/client-access.utils.ts @@ -17,8 +17,17 @@ export interface ClientAccessClientUsersRepository { } export interface RequestWithUser extends Request { - user?: { id: string; email?: string; roles?: string[]; username?: string }; + user?: { + id: string; + email?: string; + roles?: string[]; + username?: string; + amr?: string[]; + scopes?: string[]; + }; apiKeyAuthenticated?: boolean; + /** Set when Keycloak-mode accepts an app-signed PAT JWT instead of an OIDC token. */ + patAuthenticated?: boolean; } /** User info from socket auth (stored in socket.data) */ @@ -26,15 +35,26 @@ export interface SocketUserInfo { userId?: string; userRole?: UserRole; isApiKeyAuth: boolean; - user?: { id: string; email?: string; roles?: string[] }; + /** Present for users-mode JWT sockets (`pwd`; PAT sessions are rejected). */ + amr?: string[]; + user?: { id: string; email?: string; roles?: string[]; amr?: string[] }; } /** * Build a RequestWithUser-like object from socket auth data for use with ensureClientAccess. */ export function buildRequestFromSocketUser(socketUser: SocketUserInfo): RequestWithUser { + const user = + socketUser.user ?? + (socketUser.userId ? { id: socketUser.userId, roles: [] as string[], amr: socketUser.amr } : undefined); + return { - user: socketUser.user ?? (socketUser.userId ? { id: socketUser.userId, roles: [] } : undefined), + user: user + ? { + ...user, + amr: user.amr ?? socketUser.amr, + } + : undefined, apiKeyAuthenticated: socketUser.isApiKeyAuth, } as RequestWithUser; } @@ -43,6 +63,8 @@ export interface UserInfoFromRequest { userId?: string; userRole?: UserRole; isApiKeyAuth: boolean; + amr?: string[]; + scopes?: string[]; } export interface ClientAccessResult { @@ -63,7 +85,8 @@ export function canManageWorkspaceConfiguration(userInfo: UserInfoFromRequest, a return true; } - if (userInfo.userRole === UserRole.ADMIN) { + // PAT sessions must not inherit console admin powers from the JWT role claim alone. + if (userInfo.userRole === UserRole.ADMIN && !(userInfo.amr ?? []).includes('pat')) { return true; } @@ -99,6 +122,7 @@ export async function ensureWorkspaceManagementAccess( userInfo.userId, userInfo.userRole, userInfo.isApiKeyAuth, + { amr: userInfo.amr }, ); if (!access.hasAccess) { @@ -120,6 +144,7 @@ export async function assertWorkspaceManagementAccessForUser( userId: string | undefined, userRole: UserRole | undefined, isApiKeyAuth: boolean, + options?: { amr?: string[] }, ): Promise { const access = await checkClientAccess( clientsRepository, @@ -128,6 +153,7 @@ export async function assertWorkspaceManagementAccessForUser( userId, userRole, isApiKeyAuth, + options, ); if (!access.hasAccess) { @@ -136,7 +162,7 @@ export async function assertWorkspaceManagementAccessForUser( const userInfo: UserInfoFromRequest = isApiKeyAuth ? { isApiKeyAuth: true } - : { userId, userRole, isApiKeyAuth: false }; + : { userId, userRole, isApiKeyAuth: false, amr: options?.amr }; if (!canManageWorkspaceConfiguration(userInfo, access)) { throw new ForbiddenException(WORKSPACE_MANAGEMENT_FORBIDDEN_MESSAGE); @@ -171,9 +197,27 @@ export function getUserFromRequest(req: RequestWithUser): UserInfoFromRequest { userId: user.id, userRole, isApiKeyAuth: false, + amr: user.amr, + scopes: user.scopes, }; } +/** + * When the caller authenticated via PAT, require all listed scopes (no-op for password/API-key). + */ +export function assertPatScopes(userInfo: UserInfoFromRequest, ...requiredScopes: string[]): void { + if (userInfo.isApiKeyAuth || !(userInfo.amr ?? []).includes('pat')) { + return; + } + + const tokenScopes = new Set(userInfo.scopes ?? []); + const missing = requiredScopes.filter((scope) => !tokenScopes.has(scope)); + + if (missing.length > 0) { + throw new ForbiddenException(`Insufficient token scope. Missing: ${missing.join(', ')}`); + } +} + /** * Check if user has access to a client and get their client role. * @param clientsRepository - Repository for client lookup @@ -191,6 +235,7 @@ export async function checkClientAccess( userId: string | undefined, userRole: UserRole | undefined, isApiKeyAuth: boolean, + options?: { amr?: string[] }, ): Promise { if (isApiKeyAuth) { return { hasAccess: true, isClientCreator: false }; @@ -200,7 +245,8 @@ export async function checkClientAccess( return { hasAccess: false, isClientCreator: false }; } - if (userRole === UserRole.ADMIN) { + // Console admins bypass membership; PAT JWTs must not inherit that via role alone. + if (userRole === UserRole.ADMIN && !(options?.amr ?? []).includes('pat')) { return { hasAccess: true, isClientCreator: false }; } @@ -246,6 +292,7 @@ export async function ensureClientAccess( userInfo.userId, userInfo.userRole, userInfo.isApiKeyAuth, + { amr: userInfo.amr }, ); if (!access.hasAccess) { diff --git a/libs/domains/identity/backend/util-auth/src/lib/entities/user-personal-access-token.entity.spec.ts b/libs/domains/identity/backend/util-auth/src/lib/entities/user-personal-access-token.entity.spec.ts new file mode 100644 index 000000000..3bccfe006 --- /dev/null +++ b/libs/domains/identity/backend/util-auth/src/lib/entities/user-personal-access-token.entity.spec.ts @@ -0,0 +1,26 @@ +import { UserPersonalAccessTokenEntity } from './user-personal-access-token.entity'; + +describe('UserPersonalAccessTokenEntity', () => { + it('can be constructed with persisted fields', () => { + const entity = new UserPersonalAccessTokenEntity(); + + entity.id = 'token-1'; + entity.userId = 'user-1'; + entity.name = 'CI'; + entity.tokenPrefix = 'fp_pat_abcd'; + entity.tokenHash = 'hash'; + entity.scopes = ['usage:write']; + entity.expiresAt = null; + entity.revokedAt = null; + entity.lastUsedAt = null; + entity.createdAt = new Date('2026-01-01T00:00:00.000Z'); + + expect(entity).toMatchObject({ + id: 'token-1', + userId: 'user-1', + name: 'CI', + tokenPrefix: 'fp_pat_abcd', + scopes: ['usage:write'], + }); + }); +}); diff --git a/libs/domains/identity/backend/util-auth/src/lib/entities/user-personal-access-token.entity.ts b/libs/domains/identity/backend/util-auth/src/lib/entities/user-personal-access-token.entity.ts new file mode 100644 index 000000000..11eda2f33 --- /dev/null +++ b/libs/domains/identity/backend/util-auth/src/lib/entities/user-personal-access-token.entity.ts @@ -0,0 +1,40 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; + +/** + * User-bound personal access tokens for machine/API JWT exchange. + * Plaintext is shown once at create; only prefix + bcrypt hash are stored. + */ +@Entity('user_personal_access_tokens') +export class UserPersonalAccessTokenEntity { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Index('IDX_user_personal_access_tokens_user_id') + @Column({ type: 'uuid', name: 'user_id' }) + userId!: string; + + @Column({ type: 'varchar', length: 255, name: 'name' }) + name!: string; + + @Index('UQ_user_personal_access_tokens_token_prefix', { unique: true }) + @Column({ type: 'varchar', length: 32, name: 'token_prefix' }) + tokenPrefix!: string; + + @Column({ type: 'varchar', length: 255, name: 'token_hash' }) + tokenHash!: string; + + @Column({ type: 'jsonb', name: 'scopes', default: () => "'[]'::jsonb" }) + scopes!: string[]; + + @Column({ type: 'timestamp', name: 'expires_at', nullable: true }) + expiresAt?: Date | null; + + @Column({ type: 'timestamp', name: 'revoked_at', nullable: true }) + revokedAt?: Date | null; + + @Column({ type: 'timestamp', name: 'last_used_at', nullable: true }) + lastUsedAt?: Date | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; +} diff --git a/libs/domains/identity/backend/util-auth/src/lib/identity-pat.scopes.spec.ts b/libs/domains/identity/backend/util-auth/src/lib/identity-pat.scopes.spec.ts new file mode 100644 index 000000000..61685648c --- /dev/null +++ b/libs/domains/identity/backend/util-auth/src/lib/identity-pat.scopes.spec.ts @@ -0,0 +1,11 @@ +import { IDENTITY_PAT_SCOPES } from './identity-pat.scopes'; + +describe('IDENTITY_PAT_SCOPES', () => { + it('has unique entries', () => { + expect(new Set(IDENTITY_PAT_SCOPES).size).toBe(IDENTITY_PAT_SCOPES.length); + }); + + it('matches the expected shared catalog', () => { + expect(IDENTITY_PAT_SCOPES).toEqual(['users:admin', 'webhooks:admin']); + }); +}); diff --git a/libs/domains/identity/backend/util-auth/src/lib/identity-pat.scopes.ts b/libs/domains/identity/backend/util-auth/src/lib/identity-pat.scopes.ts new file mode 100644 index 000000000..26d0d1ad4 --- /dev/null +++ b/libs/domains/identity/backend/util-auth/src/lib/identity-pat.scopes.ts @@ -0,0 +1,7 @@ +/** + * Shared PAT scopes for identity surfaces used by all products. + * Product catalogs union these in (same idea as IDENTITY_EMAIL_EVENTS). + */ +export const IDENTITY_PAT_SCOPES = ['users:admin', 'webhooks:admin'] as const; + +export type IdentityPatScope = (typeof IDENTITY_PAT_SCOPES)[number]; diff --git a/libs/domains/identity/backend/util-auth/src/lib/migrations/1776100000000_CreateUserPersonalAccessTokensTable.spec.ts b/libs/domains/identity/backend/util-auth/src/lib/migrations/1776100000000_CreateUserPersonalAccessTokensTable.spec.ts new file mode 100644 index 000000000..f0c055220 --- /dev/null +++ b/libs/domains/identity/backend/util-auth/src/lib/migrations/1776100000000_CreateUserPersonalAccessTokensTable.spec.ts @@ -0,0 +1,38 @@ +import { QueryRunner } from 'typeorm'; + +import { CreateUserPersonalAccessTokensTable1776100000000 } from './1776100000000_CreateUserPersonalAccessTokensTable'; + +describe('CreateUserPersonalAccessTokensTable1776100000000', () => { + const migration = new CreateUserPersonalAccessTokensTable1776100000000(); + + it('creates the PAT table, indexes, and foreign key on up', async () => { + const queryRunner = { + createTable: jest.fn().mockResolvedValue(undefined), + createIndex: jest.fn().mockResolvedValue(undefined), + createForeignKey: jest.fn().mockResolvedValue(undefined), + } as unknown as QueryRunner; + + await migration.up(queryRunner); + + expect(queryRunner.createTable).toHaveBeenCalledTimes(1); + expect(queryRunner.createIndex).toHaveBeenCalledTimes(2); + expect(queryRunner.createForeignKey).toHaveBeenCalledTimes(1); + }); + + it('drops foreign key, indexes, and table on down', async () => { + const queryRunner = { + dropForeignKey: jest.fn().mockResolvedValue(undefined), + dropIndex: jest.fn().mockResolvedValue(undefined), + dropTable: jest.fn().mockResolvedValue(undefined), + } as unknown as QueryRunner; + + await migration.down(queryRunner); + + expect(queryRunner.dropForeignKey).toHaveBeenCalledWith( + 'user_personal_access_tokens', + 'FK_user_personal_access_tokens_user_id', + ); + expect(queryRunner.dropIndex).toHaveBeenCalledTimes(2); + expect(queryRunner.dropTable).toHaveBeenCalledWith('user_personal_access_tokens'); + }); +}); diff --git a/libs/domains/identity/backend/util-auth/src/lib/migrations/1776100000000_CreateUserPersonalAccessTokensTable.ts b/libs/domains/identity/backend/util-auth/src/lib/migrations/1776100000000_CreateUserPersonalAccessTokensTable.ts new file mode 100644 index 000000000..8d8f3e788 --- /dev/null +++ b/libs/domains/identity/backend/util-auth/src/lib/migrations/1776100000000_CreateUserPersonalAccessTokensTable.ts @@ -0,0 +1,109 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey, TableIndex } from 'typeorm'; + +export class CreateUserPersonalAccessTokensTable1776100000000 implements MigrationInterface { + name = 'CreateUserPersonalAccessTokensTable1776100000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'user_personal_access_tokens', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'uuid_generate_v4()', + }, + { + name: 'user_id', + type: 'uuid', + isNullable: false, + }, + { + name: 'name', + type: 'varchar', + length: '255', + isNullable: false, + }, + { + name: 'token_prefix', + type: 'varchar', + length: '32', + isNullable: false, + }, + { + name: 'token_hash', + type: 'varchar', + length: '255', + isNullable: false, + comment: 'bcrypt hash of full plaintext token', + }, + { + name: 'scopes', + type: 'jsonb', + isNullable: false, + default: "'[]'::jsonb", + }, + { + name: 'expires_at', + type: 'timestamp', + isNullable: true, + }, + { + name: 'revoked_at', + type: 'timestamp', + isNullable: true, + }, + { + name: 'last_used_at', + type: 'timestamp', + isNullable: true, + }, + { + name: 'created_at', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + isNullable: false, + }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'user_personal_access_tokens', + new TableIndex({ + name: 'IDX_user_personal_access_tokens_user_id', + columnNames: ['user_id'], + }), + ); + + await queryRunner.createIndex( + 'user_personal_access_tokens', + new TableIndex({ + name: 'UQ_user_personal_access_tokens_token_prefix', + columnNames: ['token_prefix'], + isUnique: true, + }), + ); + + await queryRunner.createForeignKey( + 'user_personal_access_tokens', + new TableForeignKey({ + name: 'FK_user_personal_access_tokens_user_id', + columnNames: ['user_id'], + referencedTableName: 'users', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropForeignKey('user_personal_access_tokens', 'FK_user_personal_access_tokens_user_id'); + await queryRunner.dropIndex('user_personal_access_tokens', 'UQ_user_personal_access_tokens_token_prefix'); + await queryRunner.dropIndex('user_personal_access_tokens', 'IDX_user_personal_access_tokens_user_id'); + await queryRunner.dropTable('user_personal_access_tokens'); + } +} diff --git a/libs/domains/identity/frontend/data-access-auth/src/index.ts b/libs/domains/identity/frontend/data-access-auth/src/index.ts index dd31116f1..ebee488c6 100644 --- a/libs/domains/identity/frontend/data-access-auth/src/index.ts +++ b/libs/domains/identity/frontend/data-access-auth/src/index.ts @@ -2,9 +2,15 @@ export * from './lib/user-role-labels'; export * from './lib/user-role-label.pipe'; export * from './lib/interceptors/users-session-invalidated.interceptor'; export * from './lib/services/auth.service'; +export * from './lib/types/personal-access-token.types'; export * from './lib/state/authentication/authentication.actions'; export * from './lib/state/authentication/authentication.effects'; export * from './lib/state/authentication/authentication.facade'; export * from './lib/state/authentication/authentication.reducer'; export * from './lib/state/authentication/authentication.selectors'; export * from './lib/state/authentication/authentication.types'; +export * from './lib/state/personal-access-tokens/personal-access-tokens.actions'; +export * from './lib/state/personal-access-tokens/personal-access-tokens.effects'; +export * from './lib/state/personal-access-tokens/personal-access-tokens.facade'; +export * from './lib/state/personal-access-tokens/personal-access-tokens.reducer'; +export * from './lib/state/personal-access-tokens/personal-access-tokens.selectors'; diff --git a/libs/domains/identity/frontend/data-access-auth/src/lib/services/auth.service.spec.ts b/libs/domains/identity/frontend/data-access-auth/src/lib/services/auth.service.spec.ts index 2424d844b..89765b2bf 100644 --- a/libs/domains/identity/frontend/data-access-auth/src/lib/services/auth.service.spec.ts +++ b/libs/domains/identity/frontend/data-access-auth/src/lib/services/auth.service.spec.ts @@ -190,4 +190,95 @@ describe('AuthService', () => { expect(req.request.method).toBe('POST'); req.flush({ id: 'user-1', email: 'a@b.com', role: 'user' }); }); + + it('listTokenScopes GETs /auth/token-scopes', (done) => { + service.listTokenScopes().subscribe((result) => { + expect(result).toEqual([{ scope: 'read' }]); + done(); + }); + + const req = httpMock.expectOne(`${apiUrl}/auth/token-scopes`); + + expect(req.request.method).toBe('GET'); + req.flush([{ scope: 'read' }]); + }); + + it('listPersonalAccessTokens GETs /auth/tokens', (done) => { + service.listPersonalAccessTokens().subscribe(() => done()); + + const req = httpMock.expectOne(`${apiUrl}/auth/tokens`); + + expect(req.request.method).toBe('GET'); + req.flush([]); + }); + + it('createPersonalAccessToken POSTs to /auth/tokens', (done) => { + const dto = { name: 'CI', scopes: ['read'] }; + + service.createPersonalAccessToken(dto).subscribe(() => done()); + + const req = httpMock.expectOne(`${apiUrl}/auth/tokens`); + + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(dto); + req.flush({ + id: '1', + name: 'CI', + tokenPrefix: 'fp_pat_x', + scopes: ['read'], + expiresAt: null, + revokedAt: null, + lastUsedAt: null, + createdAt: '2024-01-01T00:00:00.000Z', + token: 'fp_pat_secret', + }); + }); + + it('revokePersonalAccessToken DELETEs /auth/tokens/:id', (done) => { + service.revokePersonalAccessToken('token-1').subscribe(() => done()); + + const req = httpMock.expectOne(`${apiUrl}/auth/tokens/token-1`); + + expect(req.request.method).toBe('DELETE'); + req.flush(null); + }); + + it('updatePersonalAccessToken PATCHes /auth/tokens/:id', (done) => { + const dto = { name: 'CI', scopes: ['write'] }; + + service.updatePersonalAccessToken('token-1', dto).subscribe(() => done()); + + const req = httpMock.expectOne(`${apiUrl}/auth/tokens/token-1`); + + expect(req.request.method).toBe('PATCH'); + expect(req.request.body).toEqual(dto); + req.flush({ + id: 'token-1', + name: 'CI', + tokenPrefix: 'fp_pat_x', + scopes: ['write'], + expiresAt: null, + revokedAt: null, + lastUsedAt: null, + createdAt: '2024-01-01T00:00:00.000Z', + }); + }); + + it('listUserPersonalAccessTokens GETs /users/:userId/tokens', (done) => { + service.listUserPersonalAccessTokens('user-1').subscribe(() => done()); + + const req = httpMock.expectOne(`${apiUrl}/users/user-1/tokens`); + + expect(req.request.method).toBe('GET'); + req.flush([]); + }); + + it('revokeUserPersonalAccessToken DELETEs /users/:userId/tokens/:tokenId', (done) => { + service.revokeUserPersonalAccessToken('user-1', 'token-1').subscribe(() => done()); + + const req = httpMock.expectOne(`${apiUrl}/users/user-1/tokens/token-1`); + + expect(req.request.method).toBe('DELETE'); + req.flush(null); + }); }); diff --git a/libs/domains/identity/frontend/data-access-auth/src/lib/services/auth.service.ts b/libs/domains/identity/frontend/data-access-auth/src/lib/services/auth.service.ts index 8d3f1953d..e81c592f6 100644 --- a/libs/domains/identity/frontend/data-access-auth/src/lib/services/auth.service.ts +++ b/libs/domains/identity/frontend/data-access-auth/src/lib/services/auth.service.ts @@ -10,6 +10,12 @@ import type { UpdateUserDto, UserResponseDto, } from '../state/authentication/authentication.types'; +import type { + CreatePersonalAccessTokenDto, + PersonalAccessTokenResponseDto, + PersonalAccessTokenScopeDto, + UpdatePersonalAccessTokenDto, +} from '../types/personal-access-token.types'; /** * Injection token to configure the post-login redirect target. @@ -112,4 +118,32 @@ export class AuthService { unlockUser(id: string): Observable { return this.http.post(`${this.apiUrl}/users/${id}/unlock`, {}); } + + listTokenScopes(): Observable { + return this.http.get(`${this.apiUrl}/auth/token-scopes`); + } + + listPersonalAccessTokens(): Observable { + return this.http.get(`${this.apiUrl}/auth/tokens`); + } + + createPersonalAccessToken(dto: CreatePersonalAccessTokenDto): Observable { + return this.http.post(`${this.apiUrl}/auth/tokens`, dto); + } + + updatePersonalAccessToken(id: string, dto: UpdatePersonalAccessTokenDto): Observable { + return this.http.patch(`${this.apiUrl}/auth/tokens/${id}`, dto); + } + + revokePersonalAccessToken(id: string): Observable { + return this.http.delete(`${this.apiUrl}/auth/tokens/${id}`); + } + + listUserPersonalAccessTokens(userId: string): Observable { + return this.http.get(`${this.apiUrl}/users/${userId}/tokens`); + } + + revokeUserPersonalAccessToken(userId: string, tokenId: string): Observable { + return this.http.delete(`${this.apiUrl}/users/${userId}/tokens/${tokenId}`); + } } diff --git a/libs/domains/identity/frontend/data-access-auth/src/lib/state/authentication/authentication.effects.spec.ts b/libs/domains/identity/frontend/data-access-auth/src/lib/state/authentication/authentication.effects.spec.ts index 29e8bd2a3..c47f427c4 100644 --- a/libs/domains/identity/frontend/data-access-auth/src/lib/state/authentication/authentication.effects.spec.ts +++ b/libs/domains/identity/frontend/data-access-auth/src/lib/state/authentication/authentication.effects.spec.ts @@ -722,6 +722,74 @@ describe('AuthenticationEffects', () => { }); }); }); + + describe('when authentication type is users', () => { + const USERS_JWT_STORAGE_KEY = 'agent-controller-users-jwt'; + + beforeEach(() => { + mockAuthEnvironment = createMockIdentityAuthEnvironment({ + authentication: { + type: 'users', + }, + }); + }); + + it('returns authenticated for a valid password JWT', (done) => { + const exp = Math.floor((Date.now() + 3600000) / 1000); + const payload = btoa(JSON.stringify({ sub: 'user-1', email: 'a@b.c', exp, roles: ['admin'], amr: ['pwd'] })); + const jwt = `header.${payload}.signature`; + + actions$ = of(checkAuthentication()); + (window.localStorage.getItem as jest.Mock).mockImplementation((key: string) => + key === USERS_JWT_STORAGE_KEY ? jwt : null, + ); + + checkAuthentication$(actions$, mockAuthEnvironment, null).subscribe((result) => { + expect(result).toEqual( + checkAuthenticationSuccess({ + isAuthenticated: true, + authenticationType: 'users', + user: { id: 'user-1', email: 'a@b.c', role: 'admin' }, + }), + ); + done(); + }); + }); + + it('clears PAT JWTs and reports unauthenticated', (done) => { + const exp = Math.floor((Date.now() + 3600000) / 1000); + const payload = btoa(JSON.stringify({ sub: 'user-1', email: 'a@b.c', exp, amr: ['pat'] })); + const jwt = `header.${payload}.signature`; + + actions$ = of(checkAuthentication()); + (window.localStorage.getItem as jest.Mock).mockImplementation((key: string) => + key === USERS_JWT_STORAGE_KEY ? jwt : null, + ); + + checkAuthentication$(actions$, mockAuthEnvironment, null).subscribe((result) => { + expect(result).toEqual(checkAuthenticationSuccess({ isAuthenticated: false })); + expect(window.localStorage.removeItem).toHaveBeenCalledWith(USERS_JWT_STORAGE_KEY); + done(); + }); + }); + + it('clears expired JWTs and reports unauthenticated', (done) => { + const exp = Math.floor((Date.now() - 3600000) / 1000); + const payload = btoa(JSON.stringify({ sub: 'user-1', email: 'a@b.c', exp, amr: ['pwd'] })); + const jwt = `header.${payload}.signature`; + + actions$ = of(checkAuthentication()); + (window.localStorage.getItem as jest.Mock).mockImplementation((key: string) => + key === USERS_JWT_STORAGE_KEY ? jwt : null, + ); + + checkAuthentication$(actions$, mockAuthEnvironment, null).subscribe((result) => { + expect(result).toEqual(checkAuthenticationSuccess({ isAuthenticated: false })); + expect(window.localStorage.removeItem).toHaveBeenCalledWith(USERS_JWT_STORAGE_KEY); + done(); + }); + }); + }); }); describe('loginSuccessRedirect$', () => { diff --git a/libs/domains/identity/frontend/data-access-auth/src/lib/state/authentication/authentication.effects.ts b/libs/domains/identity/frontend/data-access-auth/src/lib/state/authentication/authentication.effects.ts index 76b02646d..bf4a94d1b 100644 --- a/libs/domains/identity/frontend/data-access-auth/src/lib/state/authentication/authentication.effects.ts +++ b/libs/domains/identity/frontend/data-access-auth/src/lib/state/authentication/authentication.effects.ts @@ -2,7 +2,7 @@ import { HttpErrorResponse } from '@angular/common/http'; import { inject } from '@angular/core'; import { Router } from '@angular/router'; import type { IdentityAuthEnvironment } from '@forepath/identity/frontend'; -import { IDENTITY_AUTH_ENVIRONMENT } from '@forepath/identity/frontend'; +import { IDENTITY_AUTH_ENVIRONMENT, jwtPayloadHasPatAmr, parseJwtPayload } from '@forepath/identity/frontend'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { KeycloakService } from 'keycloak-angular'; import { catchError, from, map, of, switchMap, tap } from 'rxjs'; @@ -315,12 +315,28 @@ export const checkAuthentication$ = createEffect( if (jwt) { try { - const payload = JSON.parse(atob(jwt.split('.')[1] ?? '{}')); - const exp = payload.exp ? payload.exp * 1000 : 0; + const payload = parseJwtPayload(jwt); + + if (!payload || jwtPayloadHasPatAmr(payload)) { + localStorage.removeItem(USERS_JWT_STORAGE_KEY); + + return of(checkAuthenticationSuccess({ isAuthenticated: false })); + } + + const exp = typeof payload['exp'] === 'number' ? payload['exp'] * 1000 : 0; const isAuthenticated = exp > Date.now(); - const role = payload.roles?.find((role: string) => role === 'admin') ? 'admin' : 'user'; + const roles = Array.isArray(payload['roles']) + ? payload['roles'].filter((role): role is string => typeof role === 'string') + : []; + const role = roles.includes('admin') ? 'admin' : 'user'; const user = - payload.sub && payload.email ? { id: payload.sub, email: payload.email, role: role } : undefined; + typeof payload['sub'] === 'string' && typeof payload['email'] === 'string' + ? { id: payload['sub'], email: payload['email'], role } + : undefined; + + if (!isAuthenticated) { + localStorage.removeItem(USERS_JWT_STORAGE_KEY); + } return of( checkAuthenticationSuccess({ diff --git a/libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.actions.ts b/libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.actions.ts new file mode 100644 index 000000000..0e6c5ee7c --- /dev/null +++ b/libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.actions.ts @@ -0,0 +1,80 @@ +import { createAction, props } from '@ngrx/store'; + +import type { + CreatePersonalAccessTokenDto, + PersonalAccessTokenResponseDto, + PersonalAccessTokenScopeDto, + UpdatePersonalAccessTokenDto, +} from '../../types/personal-access-token.types'; + +export const loadPersonalAccessTokens = createAction('[Personal Access Tokens] Load'); + +export const loadPersonalAccessTokensSuccess = createAction( + '[Personal Access Tokens] Load Success', + props<{ tokens: PersonalAccessTokenResponseDto[] }>(), +); + +export const loadPersonalAccessTokensFailure = createAction( + '[Personal Access Tokens] Load Failure', + props<{ error: string }>(), +); + +export const loadPersonalAccessTokenScopes = createAction('[Personal Access Tokens] Load Scopes'); + +export const loadPersonalAccessTokenScopesSuccess = createAction( + '[Personal Access Tokens] Load Scopes Success', + props<{ scopes: PersonalAccessTokenScopeDto[] }>(), +); + +export const loadPersonalAccessTokenScopesFailure = createAction( + '[Personal Access Tokens] Load Scopes Failure', + props<{ error: string }>(), +); + +export const createPersonalAccessToken = createAction( + '[Personal Access Tokens] Create', + props<{ dto: CreatePersonalAccessTokenDto }>(), +); + +export const createPersonalAccessTokenSuccess = createAction( + '[Personal Access Tokens] Create Success', + props<{ token: PersonalAccessTokenResponseDto }>(), +); + +export const createPersonalAccessTokenFailure = createAction( + '[Personal Access Tokens] Create Failure', + props<{ error: string }>(), +); + +export const updatePersonalAccessToken = createAction( + '[Personal Access Tokens] Update', + props<{ id: string; dto: UpdatePersonalAccessTokenDto }>(), +); + +export const updatePersonalAccessTokenSuccess = createAction( + '[Personal Access Tokens] Update Success', + props<{ token: PersonalAccessTokenResponseDto }>(), +); + +export const updatePersonalAccessTokenFailure = createAction( + '[Personal Access Tokens] Update Failure', + props<{ error: string }>(), +); + +export const revokePersonalAccessToken = createAction('[Personal Access Tokens] Revoke', props<{ id: string }>()); + +export const revokePersonalAccessTokenSuccess = createAction( + '[Personal Access Tokens] Revoke Success', + props<{ id: string }>(), +); + +export const revokePersonalAccessTokenFailure = createAction( + '[Personal Access Tokens] Revoke Failure', + props<{ error: string }>(), +); + +export const clearCreatedPersonalAccessTokenPlaintext = createAction( + '[Personal Access Tokens] Clear Created Plaintext', +); + +export const clearPersonalAccessTokensError = createAction('[Personal Access Tokens] Clear Error'); diff --git a/libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.effects.spec.ts b/libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.effects.spec.ts new file mode 100644 index 000000000..90d7cdd75 --- /dev/null +++ b/libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.effects.spec.ts @@ -0,0 +1,175 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { Actions } from '@ngrx/effects'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { of, throwError } from 'rxjs'; + +import { AuthService } from '../../services/auth.service'; + +import { + createPersonalAccessToken, + createPersonalAccessTokenFailure, + createPersonalAccessTokenSuccess, + loadPersonalAccessTokenScopes, + loadPersonalAccessTokenScopesFailure, + loadPersonalAccessTokenScopesSuccess, + loadPersonalAccessTokens, + loadPersonalAccessTokensFailure, + loadPersonalAccessTokensSuccess, + revokePersonalAccessToken, + revokePersonalAccessTokenFailure, + revokePersonalAccessTokenSuccess, + updatePersonalAccessToken, + updatePersonalAccessTokenFailure, + updatePersonalAccessTokenSuccess, +} from './personal-access-tokens.actions'; +import { + createPersonalAccessToken$, + loadPersonalAccessTokenScopes$, + loadPersonalAccessTokens$, + revokePersonalAccessToken$, + updatePersonalAccessToken$, +} from './personal-access-tokens.effects'; + +describe('PersonalAccessTokensEffects', () => { + let actions$: Actions; + let authService: { + listPersonalAccessTokens: jest.Mock; + listTokenScopes: jest.Mock; + createPersonalAccessToken: jest.Mock; + updatePersonalAccessToken: jest.Mock; + revokePersonalAccessToken: jest.Mock; + }; + const token = { + id: 't1', + name: 'CI', + tokenPrefix: 'fp_pat_x', + scopes: ['usage:write'], + expiresAt: null, + revokedAt: null, + lastUsedAt: null, + createdAt: '2026-01-01T00:00:00.000Z', + }; + + beforeEach(() => { + authService = { + listPersonalAccessTokens: jest.fn(), + listTokenScopes: jest.fn(), + createPersonalAccessToken: jest.fn(), + updatePersonalAccessToken: jest.fn(), + revokePersonalAccessToken: jest.fn(), + }; + + TestBed.configureTestingModule({ + providers: [provideMockActions(() => actions$), { provide: AuthService, useValue: authService }], + }); + }); + + it('loadPersonalAccessTokens$ succeeds', (done) => { + actions$ = of(loadPersonalAccessTokens()); + authService.listPersonalAccessTokens.mockReturnValue(of([token])); + + TestBed.runInInjectionContext(() => { + loadPersonalAccessTokens$().subscribe((result) => { + expect(result).toEqual(loadPersonalAccessTokensSuccess({ tokens: [token] })); + done(); + }); + }); + }); + + it('loadPersonalAccessTokens$ maps HttpErrorResponse message', (done) => { + actions$ = of(loadPersonalAccessTokens()); + authService.listPersonalAccessTokens.mockReturnValue( + throwError(() => new HttpErrorResponse({ error: { message: 'Nope' }, status: 403 })), + ); + + TestBed.runInInjectionContext(() => { + loadPersonalAccessTokens$().subscribe((result) => { + expect(result).toEqual(loadPersonalAccessTokensFailure({ error: 'Nope' })); + done(); + }); + }); + }); + + it('loadPersonalAccessTokenScopes$ succeeds and fails', (done) => { + const scopes = [{ scope: 'usage:write' }]; + + actions$ = of(loadPersonalAccessTokenScopes()); + authService.listTokenScopes.mockReturnValue(of(scopes)); + + TestBed.runInInjectionContext(() => { + loadPersonalAccessTokenScopes$().subscribe((result) => { + expect(result).toEqual(loadPersonalAccessTokenScopesSuccess({ scopes })); + + actions$ = of(loadPersonalAccessTokenScopes()); + authService.listTokenScopes.mockReturnValue(throwError(() => new Error('scopes failed'))); + + loadPersonalAccessTokenScopes$().subscribe((failure) => { + expect(failure).toEqual(loadPersonalAccessTokenScopesFailure({ error: 'scopes failed' })); + done(); + }); + }); + }); + }); + + it('createPersonalAccessToken$ succeeds and fails with nested error string', (done) => { + const created = { ...token, token: 'fp_pat_secret' }; + + actions$ = of(createPersonalAccessToken({ dto: { name: 'CI', scopes: ['usage:write'] } })); + authService.createPersonalAccessToken.mockReturnValue(of(created)); + + TestBed.runInInjectionContext(() => { + createPersonalAccessToken$().subscribe((result) => { + expect(result).toEqual(createPersonalAccessTokenSuccess({ token: created })); + + actions$ = of(createPersonalAccessToken({ dto: { name: 'CI', scopes: ['usage:write'] } })); + authService.createPersonalAccessToken.mockReturnValue( + throwError(() => new HttpErrorResponse({ error: { error: 'denied' }, status: 403 })), + ); + + createPersonalAccessToken$().subscribe((failure) => { + expect(failure).toEqual(createPersonalAccessTokenFailure({ error: 'denied' })); + done(); + }); + }); + }); + }); + + it('updatePersonalAccessToken$ succeeds and fails', (done) => { + actions$ = of(updatePersonalAccessToken({ id: 't1', dto: { name: 'CI2', scopes: ['usage:write'] } })); + authService.updatePersonalAccessToken.mockReturnValue(of({ ...token, name: 'CI2' })); + + TestBed.runInInjectionContext(() => { + updatePersonalAccessToken$().subscribe((result) => { + expect(result).toEqual(updatePersonalAccessTokenSuccess({ token: { ...token, name: 'CI2' } })); + + actions$ = of(updatePersonalAccessToken({ id: 't1', dto: { name: 'CI2', scopes: ['usage:write'] } })); + authService.updatePersonalAccessToken.mockReturnValue(throwError(() => 'update blew up')); + + updatePersonalAccessToken$().subscribe((failure) => { + expect(failure).toEqual(updatePersonalAccessTokenFailure({ error: 'update blew up' })); + done(); + }); + }); + }); + }); + + it('revokePersonalAccessToken$ succeeds and fails with message object', (done) => { + actions$ = of(revokePersonalAccessToken({ id: 't1' })); + authService.revokePersonalAccessToken.mockReturnValue(of(undefined)); + + TestBed.runInInjectionContext(() => { + revokePersonalAccessToken$().subscribe((result) => { + expect(result).toEqual(revokePersonalAccessTokenSuccess({ id: 't1' })); + + actions$ = of(revokePersonalAccessToken({ id: 't1' })); + authService.revokePersonalAccessToken.mockReturnValue(throwError(() => ({ message: 'gone' }))); + + revokePersonalAccessToken$().subscribe((failure) => { + expect(failure).toEqual(revokePersonalAccessTokenFailure({ error: 'gone' })); + done(); + }); + }); + }); + }); +}); diff --git a/libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.effects.ts b/libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.effects.ts new file mode 100644 index 000000000..284a0e0f8 --- /dev/null +++ b/libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.effects.ts @@ -0,0 +1,123 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { catchError, map, of, switchMap } from 'rxjs'; + +import { AuthService } from '../../services/auth.service'; + +import { + createPersonalAccessToken, + createPersonalAccessTokenFailure, + createPersonalAccessTokenSuccess, + loadPersonalAccessTokenScopes, + loadPersonalAccessTokenScopesFailure, + loadPersonalAccessTokenScopesSuccess, + loadPersonalAccessTokens, + loadPersonalAccessTokensFailure, + loadPersonalAccessTokensSuccess, + revokePersonalAccessToken, + revokePersonalAccessTokenFailure, + revokePersonalAccessTokenSuccess, + updatePersonalAccessToken, + updatePersonalAccessTokenFailure, + updatePersonalAccessTokenSuccess, +} from './personal-access-tokens.actions'; + +function normalizeError(error: unknown): string { + if (error instanceof HttpErrorResponse && error.error?.message) { + return error.error.message; + } + + if (error instanceof HttpErrorResponse && error.error?.error) { + return typeof error.error.error === 'string' ? error.error.error : String(error.error.error); + } + + if (error instanceof Error) { + return error.message; + } + + if (typeof error === 'string') { + return error; + } + + if (error && typeof error === 'object' && 'message' in error) { + return String(error.message); + } + + return 'An unexpected error occurred'; +} + +export const loadPersonalAccessTokens$ = createEffect( + (actions$ = inject(Actions), authService = inject(AuthService)) => { + return actions$.pipe( + ofType(loadPersonalAccessTokens), + switchMap(() => + authService.listPersonalAccessTokens().pipe( + map((tokens) => loadPersonalAccessTokensSuccess({ tokens })), + catchError((error) => of(loadPersonalAccessTokensFailure({ error: normalizeError(error) }))), + ), + ), + ); + }, + { functional: true }, +); + +export const loadPersonalAccessTokenScopes$ = createEffect( + (actions$ = inject(Actions), authService = inject(AuthService)) => { + return actions$.pipe( + ofType(loadPersonalAccessTokenScopes), + switchMap(() => + authService.listTokenScopes().pipe( + map((scopes) => loadPersonalAccessTokenScopesSuccess({ scopes })), + catchError((error) => of(loadPersonalAccessTokenScopesFailure({ error: normalizeError(error) }))), + ), + ), + ); + }, + { functional: true }, +); + +export const createPersonalAccessToken$ = createEffect( + (actions$ = inject(Actions), authService = inject(AuthService)) => { + return actions$.pipe( + ofType(createPersonalAccessToken), + switchMap(({ dto }) => + authService.createPersonalAccessToken(dto).pipe( + map((token) => createPersonalAccessTokenSuccess({ token })), + catchError((error) => of(createPersonalAccessTokenFailure({ error: normalizeError(error) }))), + ), + ), + ); + }, + { functional: true }, +); + +export const updatePersonalAccessToken$ = createEffect( + (actions$ = inject(Actions), authService = inject(AuthService)) => { + return actions$.pipe( + ofType(updatePersonalAccessToken), + switchMap(({ id, dto }) => + authService.updatePersonalAccessToken(id, dto).pipe( + map((token) => updatePersonalAccessTokenSuccess({ token })), + catchError((error) => of(updatePersonalAccessTokenFailure({ error: normalizeError(error) }))), + ), + ), + ); + }, + { functional: true }, +); + +export const revokePersonalAccessToken$ = createEffect( + (actions$ = inject(Actions), authService = inject(AuthService)) => { + return actions$.pipe( + ofType(revokePersonalAccessToken), + switchMap(({ id }) => + authService.revokePersonalAccessToken(id).pipe( + map(() => revokePersonalAccessTokenSuccess({ id })), + catchError((error) => of(revokePersonalAccessTokenFailure({ error: normalizeError(error) }))), + ), + ), + ); + }, + { functional: true }, +); diff --git a/libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.facade.spec.ts b/libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.facade.spec.ts new file mode 100644 index 000000000..948f4b986 --- /dev/null +++ b/libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.facade.spec.ts @@ -0,0 +1,62 @@ +import { TestBed } from '@angular/core/testing'; +import { Store } from '@ngrx/store'; +import { of } from 'rxjs'; + +import { + clearCreatedPersonalAccessTokenPlaintext, + clearPersonalAccessTokensError, + createPersonalAccessToken, + loadPersonalAccessTokenScopes, + loadPersonalAccessTokens, + revokePersonalAccessToken, + updatePersonalAccessToken, +} from './personal-access-tokens.actions'; +import { PersonalAccessTokensFacade } from './personal-access-tokens.facade'; + +describe('PersonalAccessTokensFacade', () => { + let facade: PersonalAccessTokensFacade; + let store: { select: jest.Mock; dispatch: jest.Mock }; + + beforeEach(() => { + store = { + select: jest.fn().mockReturnValue(of(true)), + dispatch: jest.fn(), + }; + + TestBed.configureTestingModule({ + providers: [PersonalAccessTokensFacade, { provide: Store, useValue: store }], + }); + + facade = TestBed.inject(PersonalAccessTokensFacade); + }); + + it('exposes store selectors', (done) => { + facade.loading$.subscribe((value) => { + expect(value).toBe(true); + expect(store.select).toHaveBeenCalled(); + done(); + }); + }); + + it('dispatches load/create/update/revoke/clear actions', () => { + facade.load(); + facade.loadScopes(); + facade.create({ name: 'CI', scopes: ['usage:write'] }); + facade.update('t1', { name: 'CI2', scopes: ['usage:write'] }); + facade.revoke('t1'); + facade.clearCreatedPlaintext(); + facade.clearError(); + + expect(store.dispatch).toHaveBeenCalledWith(loadPersonalAccessTokens()); + expect(store.dispatch).toHaveBeenCalledWith(loadPersonalAccessTokenScopes()); + expect(store.dispatch).toHaveBeenCalledWith( + createPersonalAccessToken({ dto: { name: 'CI', scopes: ['usage:write'] } }), + ); + expect(store.dispatch).toHaveBeenCalledWith( + updatePersonalAccessToken({ id: 't1', dto: { name: 'CI2', scopes: ['usage:write'] } }), + ); + expect(store.dispatch).toHaveBeenCalledWith(revokePersonalAccessToken({ id: 't1' })); + expect(store.dispatch).toHaveBeenCalledWith(clearCreatedPersonalAccessTokenPlaintext()); + expect(store.dispatch).toHaveBeenCalledWith(clearPersonalAccessTokensError()); + }); +}); diff --git a/libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.facade.ts b/libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.facade.ts new file mode 100644 index 000000000..03381103a --- /dev/null +++ b/libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.facade.ts @@ -0,0 +1,78 @@ +import { Injectable, inject } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; + +import type { + CreatePersonalAccessTokenDto, + PersonalAccessTokenResponseDto, + PersonalAccessTokenScopeDto, + UpdatePersonalAccessTokenDto, +} from '../../types/personal-access-token.types'; + +import { + clearCreatedPersonalAccessTokenPlaintext, + clearPersonalAccessTokensError, + createPersonalAccessToken, + loadPersonalAccessTokenScopes, + loadPersonalAccessTokens, + revokePersonalAccessToken, + updatePersonalAccessToken, +} from './personal-access-tokens.actions'; +import { + selectLastCreatedPersonalAccessTokenPlaintext, + selectPersonalAccessTokenScopes, + selectPersonalAccessTokenScopesError, + selectPersonalAccessTokenScopesLoading, + selectPersonalAccessTokens, + selectPersonalAccessTokensError, + selectPersonalAccessTokensLoading, + selectPersonalAccessTokensRevoking, + selectPersonalAccessTokensSaving, +} from './personal-access-tokens.selectors'; + +@Injectable({ + providedIn: 'root', +}) +export class PersonalAccessTokensFacade { + private readonly store = inject(Store); + + readonly tokens$: Observable = this.store.select(selectPersonalAccessTokens); + readonly loading$: Observable = this.store.select(selectPersonalAccessTokensLoading); + readonly error$: Observable = this.store.select(selectPersonalAccessTokensError); + readonly saving$: Observable = this.store.select(selectPersonalAccessTokensSaving); + readonly revoking$: Observable = this.store.select(selectPersonalAccessTokensRevoking); + readonly scopes$: Observable = this.store.select(selectPersonalAccessTokenScopes); + readonly scopesLoading$: Observable = this.store.select(selectPersonalAccessTokenScopesLoading); + readonly scopesError$: Observable = this.store.select(selectPersonalAccessTokenScopesError); + readonly lastCreatedPlaintext$: Observable = this.store.select( + selectLastCreatedPersonalAccessTokenPlaintext, + ); + + load(): void { + this.store.dispatch(loadPersonalAccessTokens()); + } + + loadScopes(): void { + this.store.dispatch(loadPersonalAccessTokenScopes()); + } + + create(dto: CreatePersonalAccessTokenDto): void { + this.store.dispatch(createPersonalAccessToken({ dto })); + } + + update(id: string, dto: UpdatePersonalAccessTokenDto): void { + this.store.dispatch(updatePersonalAccessToken({ id, dto })); + } + + revoke(id: string): void { + this.store.dispatch(revokePersonalAccessToken({ id })); + } + + clearCreatedPlaintext(): void { + this.store.dispatch(clearCreatedPersonalAccessTokenPlaintext()); + } + + clearError(): void { + this.store.dispatch(clearPersonalAccessTokensError()); + } +} diff --git a/libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.reducer.spec.ts b/libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.reducer.spec.ts new file mode 100644 index 000000000..e3466a12e --- /dev/null +++ b/libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.reducer.spec.ts @@ -0,0 +1,233 @@ +import { + clearCreatedPersonalAccessTokenPlaintext, + clearPersonalAccessTokensError, + createPersonalAccessToken, + createPersonalAccessTokenFailure, + createPersonalAccessTokenSuccess, + loadPersonalAccessTokenScopes, + loadPersonalAccessTokenScopesFailure, + loadPersonalAccessTokenScopesSuccess, + loadPersonalAccessTokens, + loadPersonalAccessTokensFailure, + loadPersonalAccessTokensSuccess, + revokePersonalAccessToken, + revokePersonalAccessTokenFailure, + revokePersonalAccessTokenSuccess, + updatePersonalAccessToken, + updatePersonalAccessTokenFailure, + updatePersonalAccessTokenSuccess, +} from './personal-access-tokens.actions'; +import { initialPersonalAccessTokensState, personalAccessTokensReducer } from './personal-access-tokens.reducer'; + +describe('personalAccessTokensReducer', () => { + it('sets loading and clears error on load', () => { + const state = personalAccessTokensReducer( + { ...initialPersonalAccessTokensState, error: 'old' }, + loadPersonalAccessTokens(), + ); + + expect(state.loading).toBe(true); + expect(state.error).toBeNull(); + }); + + it('stores load failure', () => { + const state = personalAccessTokensReducer( + { ...initialPersonalAccessTokensState, loading: true }, + loadPersonalAccessTokensFailure({ error: 'load failed' }), + ); + + expect(state.loading).toBe(false); + expect(state.error).toBe('load failed'); + }); + + it('loads and fails scopes', () => { + const loading = personalAccessTokensReducer(initialPersonalAccessTokensState, loadPersonalAccessTokenScopes()); + const success = personalAccessTokensReducer( + loading, + loadPersonalAccessTokenScopesSuccess({ scopes: [{ scope: 'usage:write' }] }), + ); + const failure = personalAccessTokensReducer( + loading, + loadPersonalAccessTokenScopesFailure({ error: 'scopes failed' }), + ); + + expect(loading.scopesLoading).toBe(true); + expect(success.scopes).toEqual([{ scope: 'usage:write' }]); + expect(success.scopesLoading).toBe(false); + expect(failure.scopesError).toBe('scopes failed'); + }); + + it('tracks create/update/revoke pending and failure flags', () => { + expect( + personalAccessTokensReducer( + initialPersonalAccessTokensState, + createPersonalAccessToken({ dto: { name: 'CI', scopes: [] } }), + ).saving, + ).toBe(true); + expect( + personalAccessTokensReducer( + { ...initialPersonalAccessTokensState, saving: true }, + createPersonalAccessTokenFailure({ error: 'create failed' }), + ).error, + ).toBe('create failed'); + expect( + personalAccessTokensReducer( + initialPersonalAccessTokensState, + updatePersonalAccessToken({ id: '1', dto: { name: 'CI', scopes: [] } }), + ).saving, + ).toBe(true); + expect( + personalAccessTokensReducer( + { ...initialPersonalAccessTokensState, saving: true }, + updatePersonalAccessTokenFailure({ error: 'update failed' }), + ).error, + ).toBe('update failed'); + expect( + personalAccessTokensReducer(initialPersonalAccessTokensState, revokePersonalAccessToken({ id: '1' })).revoking, + ).toBe(true); + expect( + personalAccessTokensReducer( + { ...initialPersonalAccessTokensState, revoking: true }, + revokePersonalAccessTokenFailure({ error: 'revoke failed' }), + ).error, + ).toBe('revoke failed'); + }); + + it('clears error', () => { + const state = personalAccessTokensReducer( + { ...initialPersonalAccessTokensState, error: 'old' }, + clearPersonalAccessTokensError(), + ); + + expect(state.error).toBeNull(); + }); + + it('should store loaded tokens sorted by createdAt desc', () => { + const state = personalAccessTokensReducer( + initialPersonalAccessTokensState, + loadPersonalAccessTokensSuccess({ + tokens: [ + { + id: '1', + name: 'Older', + tokenPrefix: 'fp_pat_a', + scopes: ['read'], + expiresAt: null, + revokedAt: null, + lastUsedAt: null, + createdAt: '2024-01-01T00:00:00.000Z', + }, + { + id: '2', + name: 'Newer', + tokenPrefix: 'fp_pat_b', + scopes: ['write'], + expiresAt: null, + revokedAt: null, + lastUsedAt: null, + createdAt: '2024-02-01T00:00:00.000Z', + }, + ], + }), + ); + + expect(state.tokens.map((token) => token.id)).toEqual(['2', '1']); + expect(state.loading).toBe(false); + }); + + it('should keep plaintext only in lastCreatedPlaintext on create success', () => { + const state = personalAccessTokensReducer( + initialPersonalAccessTokensState, + createPersonalAccessTokenSuccess({ + token: { + id: '1', + name: 'CI', + tokenPrefix: 'fp_pat_x', + scopes: ['read'], + expiresAt: null, + revokedAt: null, + lastUsedAt: null, + createdAt: '2024-02-01T00:00:00.000Z', + token: 'fp_pat_secret', + }, + }), + ); + + expect(state.lastCreatedPlaintext).toBe('fp_pat_secret'); + expect(state.tokens[0]?.token).toBeUndefined(); + expect(state.saving).toBe(false); + }); + + it('should upsert updated tokens without plaintext', () => { + const withToken = personalAccessTokensReducer( + initialPersonalAccessTokensState, + createPersonalAccessTokenSuccess({ + token: { + id: '1', + name: 'CI', + tokenPrefix: 'fp_pat_x', + scopes: ['read'], + expiresAt: null, + revokedAt: null, + lastUsedAt: null, + createdAt: '2024-02-01T00:00:00.000Z', + token: 'fp_pat_secret', + }, + }), + ); + const updated = personalAccessTokensReducer( + withToken, + updatePersonalAccessTokenSuccess({ + token: { + id: '1', + name: 'CI-renamed', + tokenPrefix: 'fp_pat_x', + scopes: ['write'], + expiresAt: null, + revokedAt: null, + lastUsedAt: null, + createdAt: '2024-02-01T00:00:00.000Z', + }, + }), + ); + + expect(updated.tokens).toEqual([ + { + id: '1', + name: 'CI-renamed', + tokenPrefix: 'fp_pat_x', + scopes: ['write'], + expiresAt: null, + revokedAt: null, + lastUsedAt: null, + createdAt: '2024-02-01T00:00:00.000Z', + }, + ]); + expect(updated.saving).toBe(false); + expect(updated.lastCreatedPlaintext).toBe('fp_pat_secret'); + }); + + it('should remove revoked tokens and clear plaintext', () => { + const withToken = personalAccessTokensReducer( + initialPersonalAccessTokensState, + createPersonalAccessTokenSuccess({ + token: { + id: '1', + name: 'CI', + tokenPrefix: 'fp_pat_x', + scopes: ['read'], + expiresAt: null, + revokedAt: null, + lastUsedAt: null, + createdAt: '2024-02-01T00:00:00.000Z', + token: 'fp_pat_secret', + }, + }), + ); + const revoked = personalAccessTokensReducer(withToken, revokePersonalAccessTokenSuccess({ id: '1' })); + const cleared = personalAccessTokensReducer(revoked, clearCreatedPersonalAccessTokenPlaintext()); + + expect(revoked.tokens).toEqual([]); + expect(cleared.lastCreatedPlaintext).toBeNull(); + }); +}); diff --git a/libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.reducer.ts b/libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.reducer.ts new file mode 100644 index 000000000..70a2ed664 --- /dev/null +++ b/libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.reducer.ts @@ -0,0 +1,151 @@ +import { createReducer, on } from '@ngrx/store'; + +import type { + PersonalAccessTokenResponseDto, + PersonalAccessTokenScopeDto, +} from '../../types/personal-access-token.types'; + +import { + clearCreatedPersonalAccessTokenPlaintext, + clearPersonalAccessTokensError, + createPersonalAccessToken, + createPersonalAccessTokenFailure, + createPersonalAccessTokenSuccess, + loadPersonalAccessTokenScopes, + loadPersonalAccessTokenScopesFailure, + loadPersonalAccessTokenScopesSuccess, + loadPersonalAccessTokens, + loadPersonalAccessTokensFailure, + loadPersonalAccessTokensSuccess, + revokePersonalAccessToken, + revokePersonalAccessTokenFailure, + revokePersonalAccessTokenSuccess, + updatePersonalAccessToken, + updatePersonalAccessTokenFailure, + updatePersonalAccessTokenSuccess, +} from './personal-access-tokens.actions'; + +function sortTokens(tokens: PersonalAccessTokenResponseDto[]): PersonalAccessTokenResponseDto[] { + return [...tokens].sort((a, b) => b.createdAt.localeCompare(a.createdAt) || a.name.localeCompare(b.name)); +} + +export interface PersonalAccessTokensState { + tokens: PersonalAccessTokenResponseDto[]; + loading: boolean; + saving: boolean; + revoking: boolean; + error: string | null; + scopes: PersonalAccessTokenScopeDto[]; + scopesLoading: boolean; + scopesError: string | null; + lastCreatedPlaintext: string | null; +} + +export const initialPersonalAccessTokensState: PersonalAccessTokensState = { + tokens: [], + loading: false, + saving: false, + revoking: false, + error: null, + scopes: [], + scopesLoading: false, + scopesError: null, + lastCreatedPlaintext: null, +}; + +export const personalAccessTokensReducer = createReducer( + initialPersonalAccessTokensState, + on(loadPersonalAccessTokens, (state) => ({ + ...state, + loading: true, + error: null, + })), + on(loadPersonalAccessTokensSuccess, (state, { tokens }) => ({ + ...state, + loading: false, + tokens: sortTokens(tokens), + error: null, + })), + on(loadPersonalAccessTokensFailure, (state, { error }) => ({ + ...state, + loading: false, + error, + })), + on(loadPersonalAccessTokenScopes, (state) => ({ + ...state, + scopesLoading: true, + scopesError: null, + })), + on(loadPersonalAccessTokenScopesSuccess, (state, { scopes }) => ({ + ...state, + scopesLoading: false, + scopes, + scopesError: null, + })), + on(loadPersonalAccessTokenScopesFailure, (state, { error }) => ({ + ...state, + scopesLoading: false, + scopesError: error, + })), + on(createPersonalAccessToken, (state) => ({ + ...state, + saving: true, + error: null, + })), + on(createPersonalAccessTokenSuccess, (state, { token }) => { + const { token: plaintext, ...listItem } = token; + + return { + ...state, + saving: false, + tokens: sortTokens([...state.tokens.filter((item) => item.id !== listItem.id), listItem]), + lastCreatedPlaintext: plaintext ?? null, + error: null, + }; + }), + on(createPersonalAccessTokenFailure, (state, { error }) => ({ + ...state, + saving: false, + error, + })), + on(updatePersonalAccessToken, (state) => ({ + ...state, + saving: true, + error: null, + })), + on(updatePersonalAccessTokenSuccess, (state, { token }) => ({ + ...state, + saving: false, + tokens: sortTokens([...state.tokens.filter((item) => item.id !== token.id), token]), + error: null, + })), + on(updatePersonalAccessTokenFailure, (state, { error }) => ({ + ...state, + saving: false, + error, + })), + on(revokePersonalAccessToken, (state) => ({ + ...state, + revoking: true, + error: null, + })), + on(revokePersonalAccessTokenSuccess, (state, { id }) => ({ + ...state, + revoking: false, + tokens: state.tokens.filter((item) => item.id !== id), + error: null, + })), + on(revokePersonalAccessTokenFailure, (state, { error }) => ({ + ...state, + revoking: false, + error, + })), + on(clearCreatedPersonalAccessTokenPlaintext, (state) => ({ + ...state, + lastCreatedPlaintext: null, + })), + on(clearPersonalAccessTokensError, (state) => ({ + ...state, + error: null, + })), +); diff --git a/libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.selectors.spec.ts b/libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.selectors.spec.ts new file mode 100644 index 000000000..fc7062362 --- /dev/null +++ b/libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.selectors.spec.ts @@ -0,0 +1,56 @@ +import type { PersonalAccessTokensState } from './personal-access-tokens.reducer'; +import { initialPersonalAccessTokensState } from './personal-access-tokens.reducer'; +import { + selectLastCreatedPersonalAccessTokenPlaintext, + selectPersonalAccessTokenScopes, + selectPersonalAccessTokenScopesError, + selectPersonalAccessTokenScopesLoading, + selectPersonalAccessTokens, + selectPersonalAccessTokensError, + selectPersonalAccessTokensLoading, + selectPersonalAccessTokensRevoking, + selectPersonalAccessTokensSaving, +} from './personal-access-tokens.selectors'; + +describe('personalAccessTokens selectors', () => { + const state: PersonalAccessTokensState = { + ...initialPersonalAccessTokensState, + tokens: [ + { + id: 't1', + name: 'CI', + tokenPrefix: 'fp_pat_x', + scopes: ['usage:write'], + expiresAt: null, + revokedAt: null, + lastUsedAt: null, + createdAt: '2026-01-01T00:00:00.000Z', + }, + ], + loading: true, + error: 'boom', + saving: true, + revoking: true, + scopes: [{ scope: 'usage:write' }], + scopesLoading: true, + scopesError: 'scopes boom', + lastCreatedPlaintext: 'fp_pat_secret', + }; + const project = (selector: (root: { personalAccessTokens: PersonalAccessTokensState }) => T): T => + selector({ personalAccessTokens: state }); + + it('selects list and mutation flags', () => { + expect(project(selectPersonalAccessTokens)).toEqual(state.tokens); + expect(project(selectPersonalAccessTokensLoading)).toBe(true); + expect(project(selectPersonalAccessTokensError)).toBe('boom'); + expect(project(selectPersonalAccessTokensSaving)).toBe(true); + expect(project(selectPersonalAccessTokensRevoking)).toBe(true); + }); + + it('selects scopes and created plaintext', () => { + expect(project(selectPersonalAccessTokenScopes)).toEqual(state.scopes); + expect(project(selectPersonalAccessTokenScopesLoading)).toBe(true); + expect(project(selectPersonalAccessTokenScopesError)).toBe('scopes boom'); + expect(project(selectLastCreatedPersonalAccessTokenPlaintext)).toBe('fp_pat_secret'); + }); +}); diff --git a/libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.selectors.ts b/libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.selectors.ts new file mode 100644 index 000000000..49759122e --- /dev/null +++ b/libs/domains/identity/frontend/data-access-auth/src/lib/state/personal-access-tokens/personal-access-tokens.selectors.ts @@ -0,0 +1,41 @@ +import { createFeatureSelector, createSelector } from '@ngrx/store'; + +import type { PersonalAccessTokensState } from './personal-access-tokens.reducer'; + +export const selectPersonalAccessTokensState = createFeatureSelector('personalAccessTokens'); + +export const selectPersonalAccessTokens = createSelector(selectPersonalAccessTokensState, (state) => state.tokens); + +export const selectPersonalAccessTokensLoading = createSelector( + selectPersonalAccessTokensState, + (state) => state.loading, +); + +export const selectPersonalAccessTokensError = createSelector(selectPersonalAccessTokensState, (state) => state.error); + +export const selectPersonalAccessTokensSaving = createSelector( + selectPersonalAccessTokensState, + (state) => state.saving, +); + +export const selectPersonalAccessTokensRevoking = createSelector( + selectPersonalAccessTokensState, + (state) => state.revoking, +); + +export const selectPersonalAccessTokenScopes = createSelector(selectPersonalAccessTokensState, (state) => state.scopes); + +export const selectPersonalAccessTokenScopesLoading = createSelector( + selectPersonalAccessTokensState, + (state) => state.scopesLoading, +); + +export const selectPersonalAccessTokenScopesError = createSelector( + selectPersonalAccessTokensState, + (state) => state.scopesError, +); + +export const selectLastCreatedPersonalAccessTokenPlaintext = createSelector( + selectPersonalAccessTokensState, + (state) => state.lastCreatedPlaintext, +); diff --git a/libs/domains/identity/frontend/data-access-auth/src/lib/types/personal-access-token.types.ts b/libs/domains/identity/frontend/data-access-auth/src/lib/types/personal-access-token.types.ts new file mode 100644 index 000000000..f978fd9a6 --- /dev/null +++ b/libs/domains/identity/frontend/data-access-auth/src/lib/types/personal-access-token.types.ts @@ -0,0 +1,27 @@ +export interface PersonalAccessTokenScopeDto { + scope: string; +} + +export interface PersonalAccessTokenResponseDto { + id: string; + name: string; + tokenPrefix: string; + scopes: string[]; + expiresAt: string | null; + revokedAt: string | null; + lastUsedAt: string | null; + createdAt: string; + /** Present only on create. */ + token?: string; +} + +export interface CreatePersonalAccessTokenDto { + name: string; + scopes: string[]; + expiresAt?: string; +} + +export interface UpdatePersonalAccessTokenDto { + name: string; + scopes: string[]; +} diff --git a/libs/domains/identity/frontend/feature-auth/jest.config.ts b/libs/domains/identity/frontend/feature-auth/jest.config.ts index ee111a782..db2d3ac5d 100644 --- a/libs/domains/identity/frontend/feature-auth/jest.config.ts +++ b/libs/domains/identity/frontend/feature-auth/jest.config.ts @@ -3,6 +3,8 @@ export default { preset: '../../../../../jest.preset.cjs', setupFilesAfterEnv: ['/src/test-setup.ts'], coverageDirectory: '../../../../../coverage/libs/domains/identity/frontend/feature-auth', + // Token manager is UI-only; covered by manual/e2e, not unit tests. + coveragePathIgnorePatterns: ['/src/lib/token-manager/'], transform: { '^.+\\.(ts|mjs|js|html)$': [ 'jest-preset-angular', diff --git a/libs/domains/identity/frontend/feature-auth/src/index.ts b/libs/domains/identity/frontend/feature-auth/src/index.ts index 14b5ce2ac..e9826dc8f 100644 --- a/libs/domains/identity/frontend/feature-auth/src/index.ts +++ b/libs/domains/identity/frontend/feature-auth/src/index.ts @@ -8,6 +8,7 @@ export { IdentityRequestPasswordResetComponent } from './lib/request-password-re export { IdentityResetPasswordComponent } from './lib/reset-password/reset-password.component'; export { IdentityLogoutConfirmModalComponent } from './lib/logout-confirm/logout-confirm-modal.component'; export type { LogoutConfirmResult } from './lib/logout-confirm/logout-confirm-modal.component'; +export { IdentityTokenManagerComponent } from './lib/token-manager/token-manager.component'; export { IdentityUserManagerComponent } from './lib/user-manager/user-manager.component'; // Guards diff --git a/libs/domains/identity/frontend/feature-auth/src/lib/guards/auth.guard.spec.ts b/libs/domains/identity/frontend/feature-auth/src/lib/guards/auth.guard.spec.ts index 2b1a55daf..84feda898 100644 --- a/libs/domains/identity/frontend/feature-auth/src/lib/guards/auth.guard.spec.ts +++ b/libs/domains/identity/frontend/feature-auth/src/lib/guards/auth.guard.spec.ts @@ -279,6 +279,30 @@ describe('authGuard', () => { expect(result).toBe(mockUrlTree); expect(mockRouter.createUrlTree).toHaveBeenCalledWith(['/login']); }); + + it('should clear PAT JWT and redirect to login when amr includes pat', () => { + const injector = setupTestBed({ + authentication: { + type: 'users', + }, + }); + const exp = Math.floor((Date.now() + 3600000) / 1000); + const payload = btoa(JSON.stringify({ sub: 'user-1', email: 'test@example.com', exp, amr: ['pat'] })); + const jwt = `header.${payload}.signature`; + + (window.localStorage.getItem as jest.Mock).mockImplementation((key: string) => + key === 'agent-controller-users-jwt' ? jwt : null, + ); + const mockUrlTree = {} as UrlTree; + + mockRouter.createUrlTree = jest.fn().mockReturnValue(mockUrlTree); + + const result = runInInjectionContext(injector, () => authGuard(mockRoute, mockState)); + + expect(result).toBe(mockUrlTree); + expect(window.localStorage.removeItem).toHaveBeenCalledWith('agent-controller-users-jwt'); + expect(mockRouter.createUrlTree).toHaveBeenCalledWith(['/login']); + }); }); describe('when authentication type is unknown', () => { diff --git a/libs/domains/identity/frontend/feature-auth/src/lib/guards/auth.guard.ts b/libs/domains/identity/frontend/feature-auth/src/lib/guards/auth.guard.ts index ccdec68b9..70df3514b 100644 --- a/libs/domains/identity/frontend/feature-auth/src/lib/guards/auth.guard.ts +++ b/libs/domains/identity/frontend/feature-auth/src/lib/guards/auth.guard.ts @@ -4,7 +4,14 @@ import { Router, type CanActivateFn } from '@angular/router'; // Import from util-auth barrel directly to avoid circular dependency // (feature-auth is re-exported from the @forepath/identity/frontend barrel) // eslint-disable-next-line @nx/enforce-module-boundaries -import { IDENTITY_AUTH_ENVIRONMENT, isAuthenticated, type IdentityAuthEnvironment } from '../../../../util-auth/src'; +import { + IDENTITY_AUTH_ENVIRONMENT, + isAuthenticated, + isUsersConsoleJwtValid, + jwtPayloadHasPatAmr, + parseJwtPayload, + type IdentityAuthEnvironment, +} from '../../../../util-auth/src'; /** * LocalStorage key for storing the API key @@ -19,7 +26,8 @@ const USERS_JWT_STORAGE_KEY = 'agent-controller-users-jwt'; * Guard that protects routes based on authentication configuration. * - If authentication type is 'keycloak', uses Keycloak authentication guard * - If authentication type is 'api-key', allows access if API key exists in environment or localStorage - * - If authentication type is 'users', allows access if valid JWT exists in localStorage + * - If authentication type is 'users', allows access if valid password-session JWT exists in localStorage + * - Personal access token JWTs (`amr` includes `pat`) are cleared and treated as logged out * - Otherwise, redirects to /login route */ export const authGuard: CanActivateFn = (route, state) => { @@ -56,15 +64,16 @@ export const authGuard: CanActivateFn = (route, state) => { const jwt = localStorage.getItem(USERS_JWT_STORAGE_KEY); if (jwt) { - try { - const payload = JSON.parse(atob(jwt.split('.')[1] ?? '{}')); - const exp = payload.exp ? payload.exp * 1000 : 0; - - if (exp > Date.now()) { - return true; - } - } catch { - // Invalid JWT, fall through to redirect + const payload = parseJwtPayload(jwt); + + if (jwtPayloadHasPatAmr(payload)) { + localStorage.removeItem(USERS_JWT_STORAGE_KEY); + + return router.createUrlTree(['/login']); + } + + if (isUsersConsoleJwtValid(jwt)) { + return true; } } diff --git a/libs/domains/identity/frontend/feature-auth/src/lib/guards/login.guard.spec.ts b/libs/domains/identity/frontend/feature-auth/src/lib/guards/login.guard.spec.ts index d8a075f1f..0434ef11d 100644 --- a/libs/domains/identity/frontend/feature-auth/src/lib/guards/login.guard.spec.ts +++ b/libs/domains/identity/frontend/feature-auth/src/lib/guards/login.guard.spec.ts @@ -298,6 +298,27 @@ describe('loginGuard', () => { expect(result).toBe(true); expect(mockRouter.createUrlTree).not.toHaveBeenCalled(); }); + + it('should clear PAT JWT and allow login access', () => { + const injector = setupTestBed({ + authentication: { + type: 'users', + }, + }); + const exp = Math.floor((Date.now() + 3600000) / 1000); + const payload = btoa(JSON.stringify({ sub: 'user-1', email: 'test@example.com', exp, amr: ['pat'] })); + const jwt = `header.${payload}.signature`; + + (window.localStorage.getItem as jest.Mock).mockImplementation((key: string) => + key === 'agent-controller-users-jwt' ? jwt : null, + ); + + const result = runInInjectionContext(injector, () => loginGuard(mockRoute, mockState)); + + expect(result).toBe(true); + expect(window.localStorage.removeItem).toHaveBeenCalledWith('agent-controller-users-jwt'); + expect(mockRouter.createUrlTree).not.toHaveBeenCalled(); + }); }); describe('when authentication type is unknown', () => { diff --git a/libs/domains/identity/frontend/feature-auth/src/lib/guards/login.guard.ts b/libs/domains/identity/frontend/feature-auth/src/lib/guards/login.guard.ts index 3f2b740e9..3a002a8e4 100644 --- a/libs/domains/identity/frontend/feature-auth/src/lib/guards/login.guard.ts +++ b/libs/domains/identity/frontend/feature-auth/src/lib/guards/login.guard.ts @@ -1,7 +1,12 @@ import { inject } from '@angular/core'; import { Router, type CanActivateFn } from '@angular/router'; import type { IdentityAuthEnvironment } from '@forepath/identity/frontend'; -import { IDENTITY_AUTH_ENVIRONMENT } from '@forepath/identity/frontend'; +import { + IDENTITY_AUTH_ENVIRONMENT, + isUsersConsoleJwtValid, + jwtPayloadHasPatAmr, + parseJwtPayload, +} from '@forepath/identity/frontend'; import { KeycloakService } from 'keycloak-angular'; /** @@ -17,7 +22,8 @@ const USERS_JWT_STORAGE_KEY = 'agent-controller-users-jwt'; * Guard that prevents authenticated users from accessing the login route. * - If authentication type is 'keycloak', checks if user is authenticated and redirects to /clients if so * - If authentication type is 'api-key', checks if API key exists in environment or localStorage and redirects to /clients if so - * - If authentication type is 'users', checks if valid JWT exists in localStorage and redirects to /clients if so + * - If authentication type is 'users', checks if valid password-session JWT exists in localStorage and redirects to /clients if so + * - Personal access token JWTs are cleared so the login form remains reachable * - Otherwise, allows access to login route */ export const loginGuard: CanActivateFn = (_route, _state) => { @@ -69,15 +75,16 @@ export const loginGuard: CanActivateFn = (_route, _state) => { const jwt = localStorage.getItem(USERS_JWT_STORAGE_KEY); if (jwt) { - try { - const payload = JSON.parse(atob(jwt.split('.')[1] ?? '{}')); - const exp = payload.exp ? payload.exp * 1000 : 0; - - if (exp > Date.now()) { - return router.createUrlTree(['/clients']); - } - } catch { - // Invalid JWT, allow access to login + const payload = parseJwtPayload(jwt); + + if (jwtPayloadHasPatAmr(payload)) { + localStorage.removeItem(USERS_JWT_STORAGE_KEY); + + return true; + } + + if (isUsersConsoleJwtValid(jwt)) { + return router.createUrlTree(['/clients']); } } diff --git a/libs/domains/identity/frontend/feature-auth/src/lib/guards/pat-ui.guard.spec.ts b/libs/domains/identity/frontend/feature-auth/src/lib/guards/pat-ui.guard.spec.ts new file mode 100644 index 000000000..e7eac8c69 --- /dev/null +++ b/libs/domains/identity/frontend/feature-auth/src/lib/guards/pat-ui.guard.spec.ts @@ -0,0 +1,70 @@ +import { Injector, runInInjectionContext } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot, UrlTree } from '@angular/router'; + +// eslint-disable-next-line @nx/enforce-module-boundaries +import type { IdentityAuthEnvironment } from '../../../../util-auth/src'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import { IDENTITY_AUTH_ENVIRONMENT } from '../../../../util-auth/src'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import { createMockIdentityAuthEnvironment } from '../../../../util-auth/src/lib/auth-environment.test-fixtures'; + +import { patUiGuard } from './pat-ui.guard'; + +jest.mock('keycloak-angular', () => ({ + KeycloakService: jest.fn(), + createAuthGuard: jest.fn().mockReturnValue(() => Promise.resolve(true)), +})); + +describe('patUiGuard', () => { + let mockRouter: jest.Mocked; + let mockRoute: ActivatedRouteSnapshot; + let mockState: RouterStateSnapshot; + const setup = (authentication: IdentityAuthEnvironment['authentication']): Injector => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + { + provide: IDENTITY_AUTH_ENVIRONMENT, + useValue: createMockIdentityAuthEnvironment({ authentication }), + }, + { provide: Router, useValue: mockRouter }, + ], + }); + + return TestBed.inject(Injector); + }; + + beforeEach(() => { + mockRouter = { createUrlTree: jest.fn() } as unknown as jest.Mocked; + mockRoute = {} as ActivatedRouteSnapshot; + mockState = {} as RouterStateSnapshot; + }); + + it('allows users mode', () => { + const injector = setup({ type: 'users' }); + + expect(runInInjectionContext(injector, () => patUiGuard(mockRoute, mockState))).toBe(true); + }); + + it('allows keycloak mode', () => { + const injector = setup({ + type: 'keycloak', + authServerUrl: 'http://localhost:8080', + realm: 'test', + clientId: 'test', + }); + + expect(runInInjectionContext(injector, () => patUiGuard(mockRoute, mockState))).toBe(true); + }); + + it('redirects home for api-key mode', () => { + const injector = setup({ type: 'api-key' }); + const tree = {} as UrlTree; + + mockRouter.createUrlTree.mockReturnValue(tree); + + expect(runInInjectionContext(injector, () => patUiGuard(mockRoute, mockState))).toBe(tree); + expect(mockRouter.createUrlTree).toHaveBeenCalledWith(['/']); + }); +}); diff --git a/libs/domains/identity/frontend/feature-auth/src/lib/guards/pat-ui.guard.ts b/libs/domains/identity/frontend/feature-auth/src/lib/guards/pat-ui.guard.ts new file mode 100644 index 000000000..4e541cee9 --- /dev/null +++ b/libs/domains/identity/frontend/feature-auth/src/lib/guards/pat-ui.guard.ts @@ -0,0 +1,21 @@ +import { inject } from '@angular/core'; +import { Router, type CanActivateFn } from '@angular/router'; + +// eslint-disable-next-line @nx/enforce-module-boundaries +import { IDENTITY_AUTH_ENVIRONMENT, type IdentityAuthEnvironment } from '../../../../util-auth/src'; + +/** + * Allows `/settings/tokens` only when auth mode supports PATs (users or keycloak). + * api-key mode has no PAT APIs — redirect home. + */ +export const patUiGuard: CanActivateFn = () => { + const environment = inject(IDENTITY_AUTH_ENVIRONMENT); + const router = inject(Router); + const type = environment.authentication.type; + + if (type === 'users' || type === 'keycloak') { + return true; + } + + return router.createUrlTree(['/']); +}; diff --git a/libs/domains/identity/frontend/feature-auth/src/lib/identity-auth.routes.ts b/libs/domains/identity/frontend/feature-auth/src/lib/identity-auth.routes.ts index 79ff5bddf..d74ef5e2a 100644 --- a/libs/domains/identity/frontend/feature-auth/src/lib/identity-auth.routes.ts +++ b/libs/domains/identity/frontend/feature-auth/src/lib/identity-auth.routes.ts @@ -6,8 +6,11 @@ import { checkAuthentication$, confirmEmail$, confirmEmailSuccessRedirect$, + createPersonalAccessToken$, createUser$, deleteUser$, + loadPersonalAccessTokenScopes$, + loadPersonalAccessTokens$, loadUsers$, loadUsersBatch$, lockUser$, @@ -15,13 +18,17 @@ import { loginSuccessRedirect$, logout$, logoutSuccessRedirect$, + PersonalAccessTokensFacade, + personalAccessTokensReducer, register$, registerSuccessRedirect$, requestPasswordReset$, requestPasswordResetSuccessRedirect$, resetPassword$, resetPasswordSuccessRedirect$, + revokePersonalAccessToken$, unlockUser$, + updatePersonalAccessToken$, updateUser$, } from '@forepath/identity/frontend'; import { buildPageTitle } from '@forepath/shared/frontend/util-configuration'; @@ -32,12 +39,14 @@ import { IdentityConfirmEmailComponent } from './confirm-email/confirm-email.com import { adminGuard } from './guards/admin.guard'; import { authGuard } from './guards/auth.guard'; import { loginGuard } from './guards/login.guard'; +import { patUiGuard } from './guards/pat-ui.guard'; import { signupDisabledGuard } from './guards/signup-disabled.guard'; import { IdentityLoginComponent } from './login/login.component'; import { IdentityRegisterComponent } from './register/register.component'; import { IdentityRequestPasswordResetComponent } from './request-password-reset/request-password-reset.component'; import { IdentityRequestPasswordResetConfirmationComponent } from './request-password-reset-confirmation/request-password-reset-confirmation.component'; import { IdentityResetPasswordComponent } from './reset-password/reset-password.component'; +import { IdentityTokenManagerComponent } from './token-manager/token-manager.component'; import { IdentityUserManagerComponent } from './user-manager/user-manager.component'; /** @@ -107,6 +116,12 @@ export const identityAuthRoutes: Route[] = [ component: IdentityUserManagerComponent, title: () => buildPageTitle($localize`:@@featureAuth-usersPage:User Management`), }, + { + path: 'settings/tokens', + canActivate: [authGuard, patUiGuard], + component: IdentityTokenManagerComponent, + title: () => buildPageTitle($localize`:@@featureAuth-tokensPage:Personal Access Tokens`), + }, ]; /** @@ -135,7 +150,9 @@ export const identityAuthRoutes: Route[] = [ */ export const identityAuthProviders = [ AuthenticationFacade, + PersonalAccessTokensFacade, provideState('authentication', authenticationReducer), + provideState('personalAccessTokens', personalAccessTokensReducer), provideEffects({ login$, loginSuccessRedirect$, @@ -158,5 +175,10 @@ export const identityAuthProviders = [ deleteUser$, lockUser$, unlockUser$, + loadPersonalAccessTokens$, + loadPersonalAccessTokenScopes$, + createPersonalAccessToken$, + updatePersonalAccessToken$, + revokePersonalAccessToken$, }), ]; diff --git a/libs/domains/identity/frontend/feature-auth/src/lib/login/login.component.html b/libs/domains/identity/frontend/feature-auth/src/lib/login/login.component.html index 81697e99b..8dab455b8 100644 --- a/libs/domains/identity/frontend/feature-auth/src/lib/login/login.component.html +++ b/libs/domains/identity/frontend/feature-auth/src/lib/login/login.component.html @@ -32,7 +32,12 @@

L } - @if (error$ | async; as error) { + @if (patRejectedError; as patError) { + + } @else if (error$ | async; as error) {