From c535a18fe284b4e65e471a6eae1330c81a411091 Mon Sep 17 00:00:00 2001 From: Carson Full Date: Sun, 18 May 2025 09:17:22 -0500 Subject: [PATCH 01/12] Fix EducationResolver to be decorated correctly --- src/components/user/education/education.resolver.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/user/education/education.resolver.ts b/src/components/user/education/education.resolver.ts index 44cc64a2fa..2e26c89b0a 100644 --- a/src/components/user/education/education.resolver.ts +++ b/src/components/user/education/education.resolver.ts @@ -1,5 +1,4 @@ -import { Injectable } from '@nestjs/common'; -import { Args, Mutation, Query } from '@nestjs/graphql'; +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; import { type ID, IdArg, ListArg } from '~/common'; import { Loader, type LoaderOf } from '~/core'; import { EducationLoader, EducationService } from '../education'; @@ -14,7 +13,7 @@ import { UpdateEducationOutput, } from './dto'; -@Injectable() +@Resolver() export class EducationResolver { constructor(private readonly service: EducationService) {} From 01e307eb913cb271800d34c3ac753fab270e5697 Mon Sep 17 00:00:00 2001 From: Carson Full Date: Mon, 19 May 2025 10:26:10 -0500 Subject: [PATCH 02/12] Adjust a few ~/core imports --- src/components/authorization/policy/executor/policy-dumper.ts | 3 ++- src/components/file/media/media.service.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/authorization/policy/executor/policy-dumper.ts b/src/components/authorization/policy/executor/policy-dumper.ts index abe39a3e23..c979d533d5 100644 --- a/src/components/authorization/policy/executor/policy-dumper.ts +++ b/src/components/authorization/policy/executor/policy-dumper.ts @@ -28,7 +28,8 @@ import { type Session, } from '~/common'; import { searchCamelCase } from '~/common/search-camel-case'; -import { InjectableCommand, type ResourceLike, ResourcesHost } from '~/core'; +import { InjectableCommand } from '~/core/cli'; +import { type ResourceLike, ResourcesHost } from '~/core/resources'; import { ChildListAction, ChildSingleAction, diff --git a/src/components/file/media/media.service.ts b/src/components/file/media/media.service.ts index 79d5a85034..7f5d5b654c 100644 --- a/src/components/file/media/media.service.ts +++ b/src/components/file/media/media.service.ts @@ -9,7 +9,7 @@ import { ServerException, UnauthorizedException, } from '~/common'; -import { IEventBus } from '~/core'; +import { IEventBus } from '~/core/events'; import { type FileVersion } from '../dto'; import { CanUpdateMediaUserMetadataEvent } from './events/can-update-event'; import { MediaDetector } from './media-detector.service'; From 415e2c4fd27016dcf0cd47a70f17b9daad1c7fb3 Mon Sep 17 00:00:00 2001 From: Carson Full Date: Mon, 19 May 2025 11:46:59 -0500 Subject: [PATCH 03/12] Create `Identity` service as the new authentication facade In ~/core. Migrate SessionHost consumers to this facade. --- src/common/session.ts | 6 ++-- .../authentication/authentication.module.ts | 3 ++ .../policy/executor/edge-privileges.ts | 2 +- .../policy/executor/policy-executor.ts | 8 ++--- .../policy/executor/privileges.ts | 8 ++--- .../policy/executor/resource-privileges.ts | 2 +- src/components/comments/comment.service.ts | 6 ++-- src/components/engagement/engagement.rules.ts | 10 +++--- .../update-media-metadata-check.handler.ts | 2 -- ...ss-report-workflow-notification.handler.ts | 6 ++-- src/components/project/project.service.ts | 8 ++--- .../project-workflow-notification.handler.ts | 11 +++---- .../prompt-variant-response.service.ts | 6 ++-- .../user/education/education.service.ts | 6 ++-- .../unavailability/unavailability.service.ts | 6 ++-- src/components/user/user.service.ts | 6 ++-- src/core/authentication/identity.service.ts | 32 +++++++++++++++++++ src/core/authentication/index.ts | 1 + src/core/data-loader/data-loader.config.ts | 6 ++-- src/core/database/database.service.ts | 6 ++-- src/core/gel/gel.service.ts | 8 ++--- src/core/graphql/graphql-tracing.plugin.ts | 6 ++-- 22 files changed, 94 insertions(+), 61 deletions(-) create mode 100644 src/core/authentication/identity.service.ts create mode 100644 src/core/authentication/index.ts diff --git a/src/common/session.ts b/src/common/session.ts index 8b3e4d231f..571d76215c 100644 --- a/src/common/session.ts +++ b/src/common/session.ts @@ -8,7 +8,7 @@ import { CONTROLLER_WATERMARK } from '@nestjs/common/constants.js'; import { Context } from '@nestjs/graphql'; import { uniq } from 'lodash'; import { type DateTime } from 'luxon'; -import { SessionHost } from '../components/authentication/session.host'; +import { Identity } from '~/core/authentication'; import { type ScopedRole } from '../components/authorization/dto'; import { UnauthenticatedException } from './exceptions'; import { type ID } from './id-field'; @@ -42,10 +42,10 @@ export function loggedInSession(session: Session): Session { @Injectable() export class SessionPipe implements PipeTransform { - constructor(private readonly sessionHost: SessionHost) {} + constructor(private readonly identity: Identity) {} transform() { - return this.sessionHost.currentMaybe; + return this.identity.currentMaybe; } } diff --git a/src/components/authentication/authentication.module.ts b/src/components/authentication/authentication.module.ts index 99fd2a1d03..f8a098dd00 100644 --- a/src/components/authentication/authentication.module.ts +++ b/src/components/authentication/authentication.module.ts @@ -2,6 +2,7 @@ import { forwardRef, Global, Module } from '@nestjs/common'; import { APP_INTERCEPTOR } from '@nestjs/core'; import { SessionPipe } from '~/common/session'; import { splitDb } from '~/core'; +import { Identity } from '~/core/authentication'; import { AuthorizationModule } from '../authorization/authorization.module'; import { UserModule } from '../user/user.module'; import { AuthenticationGelRepository } from './authentication.gel.repository'; @@ -34,6 +35,7 @@ import { SessionResolver } from './session.resolver'; SessionExtraInfoResolver, LoginExtraInfoResolver, RegisterExtraInfoResolver, + Identity, AuthenticationService, splitDb(AuthenticationRepository, AuthenticationGelRepository), { provide: 'AUTHENTICATION', useExisting: AuthenticationService }, @@ -44,6 +46,7 @@ import { SessionResolver } from './session.resolver'; SessionPipe, ], exports: [ + Identity, SessionHost, SessionInterceptor, AuthenticationService, diff --git a/src/components/authorization/policy/executor/edge-privileges.ts b/src/components/authorization/policy/executor/edge-privileges.ts index 06c9772ebc..bf1f66d7b0 100644 --- a/src/components/authorization/policy/executor/edge-privileges.ts +++ b/src/components/authorization/policy/executor/edge-privileges.ts @@ -57,7 +57,7 @@ export class EdgePrivileges< : perm.isAllowed({ object: this.object, resource: this.resource, - session: this.policyExecutor.sessionHost.current, + session: this.policyExecutor.identity.current, }); } diff --git a/src/components/authorization/policy/executor/policy-executor.ts b/src/components/authorization/policy/executor/policy-executor.ts index 7e362c7de3..ed5c817412 100644 --- a/src/components/authorization/policy/executor/policy-executor.ts +++ b/src/components/authorization/policy/executor/policy-executor.ts @@ -2,8 +2,8 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { CachedByArg } from '@seedcompany/common'; import { identity, intersection } from 'lodash'; import { type EnhancedResource, type Session } from '~/common'; +import { Identity } from '~/core/authentication'; import { type QueryFragment } from '~/core/database/query'; -import { SessionHost } from '../../../authentication/session.host'; import { withoutScope } from '../../dto'; import { RoleCondition } from '../../policies/conditions/role.condition'; import { type Permission } from '../builder/perm-granter'; @@ -40,7 +40,7 @@ export interface FilterOptions { @Injectable() export class PolicyExecutor { constructor( - readonly sessionHost: SessionHost, + readonly identity: Identity, private readonly policyFactory: PolicyFactory, @Inject(forwardRef(() => ConditionOptimizer)) private readonly conditionOptimizer: ConditionOptimizer & {}, @@ -64,7 +64,7 @@ export class PolicyExecutor { } } - const session = this.sessionHost.current; + const session = this.identity.current; const policies = this.getPolicies(session); const isChildRelation = prop && resource.childKeys.has(prop); @@ -187,7 +187,7 @@ export class PolicyExecutor { const other = { resource: params.resource, - session: this.sessionHost.current, + session: this.identity.current, }; return query .comment("Loading policy condition's context") diff --git a/src/components/authorization/policy/executor/privileges.ts b/src/components/authorization/policy/executor/privileges.ts index 7bcd48cd29..7128d823db 100644 --- a/src/components/authorization/policy/executor/privileges.ts +++ b/src/components/authorization/policy/executor/privileges.ts @@ -6,7 +6,7 @@ import { type ResourceShape, type SecuredPropsPlusExtraKey, } from '~/common'; -import { SessionHost } from '../../../authentication/session.host'; +import { Identity } from '~/core/authentication'; import type { Power } from '../../dto'; import { MissingPowerException } from '../../missing-power.exception'; import { @@ -23,7 +23,7 @@ import { ResourcePrivileges } from './resource-privileges'; export class Privileges { constructor( private readonly policyExecutor: PolicyExecutor, - private readonly sessionHost: SessionHost, + private readonly identity: Identity, ) {} forResource>( @@ -90,7 +90,7 @@ export class Privileges { * I think this should be replaced in-app code with `.for(X).verifyCan('create')` */ verifyPower(power: Power) { - const session = this.sessionHost.current; + const session = this.identity.current; if (!this.powers.has(power)) { throw new MissingPowerException( power, @@ -102,7 +102,7 @@ export class Privileges { } get powers(): Set { - const session = this.sessionHost.current; + const session = this.identity.current; const policies = this.policyExecutor.getPolicies(session); return new Set(policies.flatMap((policy) => [...policy.powers])); } diff --git a/src/components/authorization/policy/executor/resource-privileges.ts b/src/components/authorization/policy/executor/resource-privileges.ts index de6e79871c..7c0fbdde0d 100644 --- a/src/components/authorization/policy/executor/resource-privileges.ts +++ b/src/components/authorization/policy/executor/resource-privileges.ts @@ -106,7 +106,7 @@ export class ResourcePrivileges> { : perm.isAllowed({ object: this.object, resource: this.resource, - session: this.policyExecutor.sessionHost.current, + session: this.policyExecutor.identity.current, }); } diff --git a/src/components/comments/comment.service.ts b/src/components/comments/comment.service.ts index dd5214454d..70442df3d9 100644 --- a/src/components/comments/comment.service.ts +++ b/src/components/comments/comment.service.ts @@ -13,8 +13,8 @@ import { } from '~/common'; import { isAdmin } from '~/common/session'; import { ResourceLoader, ResourcesHost } from '~/core'; +import { Identity } from '~/core/authentication'; import { type BaseNode, isBaseNode } from '~/core/database/results'; -import { SessionHost } from '../authentication'; import { Privileges } from '../authorization'; import { CommentRepository } from './comment.repository'; import { @@ -39,7 +39,7 @@ export class CommentService { private readonly privileges: Privileges, private readonly resources: ResourceLoader, private readonly resourcesHost: ResourcesHost, - private readonly sessionHost: SessionHost, + private readonly identity: Identity, private readonly mentionNotificationService: CommentViaMentionNotificationService, ) {} @@ -118,7 +118,7 @@ export class CommentService { } secureThread(thread: UnsecuredDto): CommentThread { - const session = this.sessionHost.current; + const session = this.identity.current; return { ...thread, firstComment: this.secureComment(thread.firstComment), diff --git a/src/components/engagement/engagement.rules.ts b/src/components/engagement/engagement.rules.ts index beba082a05..d8d32e64b7 100644 --- a/src/components/engagement/engagement.rules.ts +++ b/src/components/engagement/engagement.rules.ts @@ -9,9 +9,9 @@ import { UnauthorizedException, } from '~/common'; import { ILogger, Logger } from '~/core'; +import { Identity } from '~/core/authentication'; import { DatabaseService } from '~/core/database'; import { ACTIVE, INACTIVE } from '~/core/database/query'; -import { SessionHost } from '../authentication'; import { withoutScope } from '../authorization/dto'; import { ProjectStep } from '../project/dto'; import { @@ -35,7 +35,7 @@ const rolesThatCanBypassWorkflow: Role[] = [Role.Administrator]; export class EngagementRules { constructor( private readonly db: DatabaseService, - private readonly sessionHost: SessionHost, + private readonly identity: Identity, // eslint-disable-next-line @seedcompany/no-unused-vars @Logger('engagement:rules') private readonly logger: ILogger, ) {} @@ -317,7 +317,7 @@ export class EngagementRules { currentUserRoles?: Role[], changeset?: ID, ): Promise { - const session = this.sessionHost.current; + const session = this.identity.current; if (session.anonymous) { return []; } @@ -357,7 +357,7 @@ export class EngagementRules { } async canBypassWorkflow() { - const session = this.sessionHost.current; + const session = this.identity.current; const roles = session.roles.map(withoutScope); return intersection(rolesThatCanBypassWorkflow, roles).length > 0; } @@ -369,7 +369,7 @@ export class EngagementRules { ) { // If current user's roles include a role that can bypass workflow // stop the check here. - const session = this.sessionHost.current; + const session = this.identity.current; const currentUserRoles = session.roles.map(withoutScope); if (intersection(rolesThatCanBypassWorkflow, currentUserRoles).length > 0) { return; diff --git a/src/components/progress-report/media/handlers/update-media-metadata-check.handler.ts b/src/components/progress-report/media/handlers/update-media-metadata-check.handler.ts index 5cd3bfd47b..105d45325e 100644 --- a/src/components/progress-report/media/handlers/update-media-metadata-check.handler.ts +++ b/src/components/progress-report/media/handlers/update-media-metadata-check.handler.ts @@ -1,5 +1,4 @@ import { EventsHandler, ResourceLoader } from '~/core'; -import { SessionHost } from '../../../authentication'; import { Privileges } from '../../../authorization'; import { CanUpdateMediaUserMetadataEvent } from '../../../file/media/events/can-update-event'; import { ProgressReportMedia as ReportMedia } from '../dto'; @@ -9,7 +8,6 @@ export class ProgressReportUpdateMediaMetadataCheckHandler { constructor( private readonly resources: ResourceLoader, private readonly privileges: Privileges, - private readonly sessionHost: SessionHost, ) {} async handle(event: CanUpdateMediaUserMetadataEvent) { diff --git a/src/components/progress-report/workflow/handlers/progress-report-workflow-notification.handler.ts b/src/components/progress-report/workflow/handlers/progress-report-workflow-notification.handler.ts index 58c2aeca9a..24a0a0ce3e 100644 --- a/src/components/progress-report/workflow/handlers/progress-report-workflow-notification.handler.ts +++ b/src/components/progress-report/workflow/handlers/progress-report-workflow-notification.handler.ts @@ -9,11 +9,11 @@ import { ILogger, Logger, } from '~/core'; +import { Identity } from '~/core/authentication'; import { type ProgressReportStatusChangedProps as EmailReportStatusNotification, ProgressReportStatusChanged, } from '~/core/email/templates/progress-report-status-changed.template'; -import { AuthenticationService } from '../../../authentication'; import { LanguageService } from '../../../language'; import { PeriodicReportService } from '../../../periodic-report'; import { ProjectService } from '../../../project'; @@ -36,7 +36,7 @@ export class ProgressReportWorkflowNotificationHandler implements IEventHandler { constructor( - private readonly auth: AuthenticationService, + private readonly identity: Identity, private readonly repo: ProgressReportWorkflowRepository, private readonly configService: ConfigService, private readonly userService: UserService, @@ -113,7 +113,7 @@ export class ProgressReportWorkflowNotificationHandler languageId: ID, ): Promise { const recipientId = receiver.userId ?? this.configService.rootUser.id; - return await this.auth.asUser(recipientId, async () => { + return await this.identity.asUser(recipientId, async () => { const recipient = receiver.userId ? await this.userService.readOne(recipientId) : this.fakeUserFromEmailAddress(receiver.email!); diff --git a/src/components/project/project.service.ts b/src/components/project/project.service.ts index aae0cf3c7c..41d7d9b4a4 100644 --- a/src/components/project/project.service.ts +++ b/src/components/project/project.service.ts @@ -22,9 +22,9 @@ import { } from '~/common'; import { isAdmin } from '~/common/session'; import { HandleIdLookup, IEventBus } from '~/core'; +import { Identity } from '~/core/authentication'; import { Transactional } from '~/core/database'; import { type AnyChangesOf } from '~/core/database/changes'; -import { SessionHost } from '../authentication'; import { Privileges } from '../authorization'; import { withoutScope } from '../authorization/dto'; import { BudgetService } from '../budget'; @@ -89,7 +89,7 @@ export class ProjectService { @Inject(forwardRef(() => EngagementService)) private readonly engagementService: EngagementService & {}, private readonly privileges: Privileges, - private readonly sessionHost: SessionHost, + private readonly identity: Identity, private readonly eventBus: IEventBus, private readonly repo: ProjectRepository, private readonly projectChangeRequests: ProjectChangeRequestService, @@ -137,7 +137,7 @@ export class ProjectService { ); // Only allow admins to specify department IDs - const session = this.sessionHost.current; + const session = this.identity.current; if (input.departmentId && !isAdmin(session.impersonator ?? session)) { throw UnauthorizedException.fromPrivileges( 'edit', @@ -252,7 +252,7 @@ export class ProjectService { ); // Only allow admins to specify department IDs - const session = this.sessionHost.current; + const session = this.identity.current; if ( input.departmentId !== undefined && !isAdmin(session.impersonator ?? session) diff --git a/src/components/project/workflow/handlers/project-workflow-notification.handler.ts b/src/components/project/workflow/handlers/project-workflow-notification.handler.ts index 6448f91c63..3553cef8f2 100644 --- a/src/components/project/workflow/handlers/project-workflow-notification.handler.ts +++ b/src/components/project/workflow/handlers/project-workflow-notification.handler.ts @@ -9,11 +9,11 @@ import { ILogger, Logger, } from '~/core'; +import { Identity } from '~/core/authentication'; import { ProjectStepChanged, type ProjectStepChangedProps, } from '~/core/email/templates/project-step-changed.template'; -import { AuthenticationService, SessionHost } from '../../../authentication'; import { ProjectService } from '../../../project'; import { UserService } from '../../../user'; import { type User } from '../../../user/dto'; @@ -26,12 +26,11 @@ export class ProjectWorkflowNotificationHandler implements IEventHandler { constructor( - private readonly auth: AuthenticationService, + private readonly identity: Identity, private readonly config: ConfigService, private readonly users: UserService, private readonly projects: ProjectService, private readonly emailService: EmailService, - private readonly sessionHost: SessionHost, private readonly moduleRef: ModuleRef, @Logger('progress-report:status-change-notifier') private readonly logger: ILogger, @@ -41,7 +40,7 @@ export class ProjectWorkflowNotificationHandler const { previousStep, next, workflowEvent } = event; const transition = typeof next !== 'string' ? next : undefined; - const session = this.sessionHost.current; + const session = this.identity.current; // TODO on bypass: keep notifying members? add anyone else? const notifiers = transition?.notifiers ?? []; @@ -74,7 +73,7 @@ export class ProjectWorkflowNotificationHandler toStep: event.workflowEvent.to, }); - const [changedBy, project, primaryPartnerName] = await this.auth.asUser( + const [changedBy, project, primaryPartnerName] = await this.identity.asUser( this.config.rootUser.id, async () => await Promise.all([ @@ -108,7 +107,7 @@ export class ProjectWorkflowNotificationHandler primaryPartnerName: string | null, ): Promise { const recipientId = notifier.id ?? this.config.rootUser.id; - return await this.auth.asUser(recipientId, async () => { + return await this.identity.asUser(recipientId, async () => { const recipient = notifier.id ? await this.users.readOne(recipientId) : ({ diff --git a/src/components/prompts/prompt-variant-response.service.ts b/src/components/prompts/prompt-variant-response.service.ts index 8390dfa498..4598669bc8 100644 --- a/src/components/prompts/prompt-variant-response.service.ts +++ b/src/components/prompts/prompt-variant-response.service.ts @@ -16,8 +16,8 @@ import { type VariantOf, } from '~/common'; import { ResourceLoader } from '~/core'; +import { Identity } from '~/core/authentication'; import { mapListResults } from '~/core/database/results'; -import { SessionHost } from '../authentication'; import { Privileges, type UserResourcePrivileges, @@ -53,7 +53,7 @@ export const PromptVariantResponseListService = < abstract class PromptVariantResponseListServiceClass { @Inject(Privileges) protected readonly privileges: Privileges; - @Inject() protected readonly sessionHost: SessionHost; + @Inject() protected readonly identity: Identity; @Inject(ResourceLoader) protected readonly resources: ResourceLoader; @Inject(repo) @@ -230,7 +230,7 @@ export const PromptVariantResponseListService = < await this.repo.submitResponse(input); } - const session = this.sessionHost.current; + const session = this.identity.current; const responses = mapKeys.fromList( response.responses, (response) => response.variant, diff --git a/src/components/user/education/education.service.ts b/src/components/user/education/education.service.ts index 8b26f2e73c..c2d8638ba0 100644 --- a/src/components/user/education/education.service.ts +++ b/src/components/user/education/education.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { type ID, type ObjectView, type UnsecuredDto } from '~/common'; import { HandleIdLookup } from '~/core'; -import { SessionHost } from '../../authentication'; +import { Identity } from '~/core/authentication'; import { Privileges } from '../../authorization'; import { type CreateEducation, @@ -16,7 +16,7 @@ import { EducationRepository } from './education.repository'; export class EducationService { constructor( private readonly privileges: Privileges, - private readonly sessionHost: SessionHost, + private readonly identity: Identity, private readonly repo: EducationRepository, ) {} @@ -47,7 +47,7 @@ export class EducationService { const result = await this.repo.getUserIdByEducation(input.id); const changes = this.repo.getActualChanges(ed, input); // TODO move this condition into policies - const session = this.sessionHost.current; + const session = this.identity.current; if (result.id !== session.userId) { this.privileges.for(Education, ed).verifyChanges(changes); } diff --git a/src/components/user/unavailability/unavailability.service.ts b/src/components/user/unavailability/unavailability.service.ts index eab1aff997..76f0674478 100644 --- a/src/components/user/unavailability/unavailability.service.ts +++ b/src/components/user/unavailability/unavailability.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { type ID, type ObjectView, type UnsecuredDto } from '~/common'; import { HandleIdLookup } from '~/core'; -import { SessionHost } from '../../authentication'; +import { Identity } from '~/core/authentication'; import { Privileges } from '../../authorization'; import { type CreateUnavailability, @@ -16,7 +16,7 @@ import { UnavailabilityRepository } from './unavailability.repository'; export class UnavailabilityService { constructor( private readonly privileges: Privileges, - private readonly sessionHost: SessionHost, + private readonly identity: Identity, private readonly repo: UnavailabilityRepository, ) {} @@ -46,7 +46,7 @@ export class UnavailabilityService { const result = await this.repo.getUserIdByUnavailability(input.id); const changes = this.repo.getActualChanges(unavailability, input); // TODO move this condition into policies - const session = this.sessionHost.current; + const session = this.identity.current; if (result.id !== session.userId) { this.privileges .for(Unavailability, unavailability) diff --git a/src/components/user/user.service.ts b/src/components/user/user.service.ts index b4716ef0a1..fb4543a7ee 100644 --- a/src/components/user/user.service.ts +++ b/src/components/user/user.service.ts @@ -9,9 +9,9 @@ import { type UnsecuredDto, } from '~/common'; import { HandleIdLookup, ILogger, Logger } from '~/core'; +import { Identity } from '~/core/authentication'; import { Transactional } from '~/core/database'; import { property } from '~/core/database/query'; -import { SessionHost } from '../authentication/session.host'; import { Privileges } from '../authorization'; import { AssignableRoles } from '../authorization/dto/assignable-roles.dto'; import { LocationService } from '../location'; @@ -61,7 +61,7 @@ export class UserService { private readonly privileges: Privileges, private readonly locationService: LocationService, private readonly knownLanguages: KnownLanguageRepository, - private readonly sessionHost: SessionHost, + private readonly identity: Identity, private readonly userRepo: UserRepository, @Logger('user:service') private readonly logger: ILogger, ) {} @@ -77,7 +77,7 @@ export class UserService { input.roles && input.roles.length > 0 && // Note: session is only omitted for creating RootUser - this.sessionHost.currentIfInCtx + this.identity.currentIfInCtx ) { this.verifyRolesAreAssignable(input.roles); } diff --git a/src/core/authentication/identity.service.ts b/src/core/authentication/identity.service.ts new file mode 100644 index 0000000000..bc04ffa838 --- /dev/null +++ b/src/core/authentication/identity.service.ts @@ -0,0 +1,32 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { ID } from '~/common'; +import { type AuthenticationService } from '../../components/authentication'; +import { SessionHost } from '../../components/authentication/session.host'; + +/** + * A facade for authentication functionality that is public to the codebase. + */ +@Injectable() +export class Identity { + constructor( + @Inject('AUTHENTICATION') + private readonly auth: AuthenticationService & {}, + private readonly sessionHost: SessionHost, + ) {} + + get current() { + return this.sessionHost.current; + } + + get currentMaybe() { + return this.sessionHost.currentMaybe; + } + + get currentIfInCtx() { + return this.sessionHost.currentIfInCtx; + } + + async asUser(user: ID<'User'>, fn: () => Promise): Promise { + return await this.auth.asUser(user, fn); + } +} diff --git a/src/core/authentication/index.ts b/src/core/authentication/index.ts new file mode 100644 index 0000000000..bd240b7005 --- /dev/null +++ b/src/core/authentication/index.ts @@ -0,0 +1 @@ +export * from './identity.service'; diff --git a/src/core/data-loader/data-loader.config.ts b/src/core/data-loader/data-loader.config.ts index 0b13edb291..6714f168e3 100644 --- a/src/core/data-loader/data-loader.config.ts +++ b/src/core/data-loader/data-loader.config.ts @@ -4,14 +4,14 @@ import { lifetimeIdFromExecutionContext, } from '@seedcompany/data-loader'; import { NotFoundException } from '~/common'; -import { SessionHost } from '../../components/authentication'; +import { Identity } from '../authentication'; import { ConfigService } from '../config/config.service'; @Injectable() export class DataLoaderConfig { constructor( private readonly config: ConfigService, - private readonly sessionHost: SessionHost, + private readonly identity: Identity, ) {} create(): DataLoaderOptions { @@ -27,7 +27,7 @@ export class DataLoaderConfig { // If we have a session, use that as the cache key. // It will always be created / scoped within the GQL operation. // This ensures the cached data isn't shared between users. - const session = this.sessionHost.currentMaybe; + const session = this.identity.currentMaybe; if (session) return session; return lifetimeIdFromExecutionContext(context); diff --git a/src/core/database/database.service.ts b/src/core/database/database.service.ts index e8716053f8..3c9c76fdc6 100644 --- a/src/core/database/database.service.ts +++ b/src/core/database/database.service.ts @@ -17,7 +17,7 @@ import { type UnwrapSecured, } from '~/common'; import { AbortError, retry, type RetryOptions } from '~/common/retry'; -import { SessionHost } from '../../components/authentication/session.host'; +import { Identity } from '../authentication'; import { ConfigService } from '../config/config.service'; import { ILogger, Logger } from '../logger'; import { ShutdownHook } from '../shutdown.hook'; @@ -64,7 +64,7 @@ export class DatabaseService { constructor( private readonly db: Connection, private readonly config: ConfigService, - private readonly sessionHost: SessionHost, + private readonly identity: Identity, private readonly shutdown$: ShutdownHook, @Logger('database:service') private readonly logger: ILogger, ) {} @@ -124,7 +124,7 @@ export class DatabaseService { parameters?: Record, ): Query { const q = this.db.query() as Query; - q.params.addParam(this.sessionHost.currentIfInCtx?.userId, 'currentUser'); + q.params.addParam(this.identity.currentIfInCtx?.userId, 'currentUser'); if (query) { q.raw(query, parameters); } diff --git a/src/core/gel/gel.service.ts b/src/core/gel/gel.service.ts index 1b41fc7bb9..6685d2ebb1 100644 --- a/src/core/gel/gel.service.ts +++ b/src/core/gel/gel.service.ts @@ -6,7 +6,7 @@ import { type QueryArgs } from 'gel/dist/ifaces'; import { TraceLayer } from '~/common'; import { retry, type RetryOptions } from '~/common/retry'; import { TracingService } from '~/core/tracing'; -import { SessionHost } from '../../components/authentication/session.host'; +import { Identity } from '../authentication'; import { TypedEdgeQL } from './edgeql'; import { cleanError } from './errors'; import { InlineQueryRuntimeMap } from './generated-client/inline-queries'; @@ -23,7 +23,7 @@ export class Gel { private readonly transactionContext: TransactionContext, private readonly optionsContext: OptionsContext, private readonly tracing: TracingService, - private readonly sessionHost: SessionHost, + private readonly identity: Identity, @Optional() private readonly childOptions: ApplyOptions[] = [], @Optional() private childExecutor?: Executor, ) {} @@ -34,7 +34,7 @@ export class Gel { this.transactionContext, this.optionsContext, this.tracing, - this.sessionHost, + this.identity, [...this.childOptions], this.childExecutor, ); @@ -115,7 +115,7 @@ export class Gel { const queryNames = getCurrentQueryNames(); const traceName = queryNames?.xray ?? 'Query'; - let currentActorId = this.sessionHost.currentIfInCtx?.userId; + let currentActorId = this.identity.currentIfInCtx?.userId; // TODO temporarily check if UUID before applying global. // Once migration is complete this can be removed. currentActorId = isUUID(currentActorId) ? currentActorId : undefined; diff --git a/src/core/graphql/graphql-tracing.plugin.ts b/src/core/graphql/graphql-tracing.plugin.ts index 34fa931623..9000e1d05e 100644 --- a/src/core/graphql/graphql-tracing.plugin.ts +++ b/src/core/graphql/graphql-tracing.plugin.ts @@ -4,7 +4,7 @@ import { type GraphQLResolveInfo as ResolveInfo, type ResponsePath, } from 'graphql'; -import { SessionHost } from '../../components/authentication/session.host'; +import { Identity } from '../authentication'; import { type Segment, TracingService } from '../tracing'; import { Plugin } from './plugin.decorator'; @@ -12,7 +12,7 @@ import { Plugin } from './plugin.decorator'; export class GraphqlTracingPlugin { constructor( private readonly tracing: TracingService, - private readonly sessionHost: SessionHost, + private readonly identity: Identity, ) {} onExecute: Plugin['onExecute'] = ({ args }) => { @@ -47,7 +47,7 @@ export class GraphqlTracingPlugin { return { onExecuteDone: () => { - const userId = this.sessionHost.currentMaybe?.userId; + const userId = this.identity.currentMaybe?.userId; if (userId) { segment.setUser?.(userId); } From cb5fce8c9093d0a95a64c8eee4d45792b8f9976b Mon Sep 17 00:00:00 2001 From: Carson Full Date: Mon, 19 May 2025 11:49:38 -0500 Subject: [PATCH 04/12] Create `Identity.asRole` This moves all session creations into the service. --- .../authentication/authentication.service.ts | 11 +++ .../policy/executor/policy-dumper.ts | 95 ++++++++----------- .../workflow/permission.serializer.ts | 16 +--- src/components/workflow/workflow.service.ts | 6 +- src/core/authentication/identity.service.ts | 9 +- 5 files changed, 68 insertions(+), 69 deletions(-) diff --git a/src/components/authentication/authentication.service.ts b/src/components/authentication/authentication.service.ts index 062292f0d7..25eb906ff2 100644 --- a/src/components/authentication/authentication.service.ts +++ b/src/components/authentication/authentication.service.ts @@ -256,6 +256,17 @@ export class AuthenticationService { return session; } + asRole(role: Role, fn: () => R): R { + const session: Session = { + token: 'system', + issuedAt: DateTime.now(), + userId: 'anonymous' as ID, + anonymous: false, + roles: [`global:${role}`], + }; + return this.sessionHost.withSession(session, fn); + } + async changePassword( oldPassword: string, newPassword: string, diff --git a/src/components/authorization/policy/executor/policy-dumper.ts b/src/components/authorization/policy/executor/policy-dumper.ts index c979d533d5..5a7896cc4d 100644 --- a/src/components/authorization/policy/executor/policy-dumper.ts +++ b/src/components/authorization/policy/executor/policy-dumper.ts @@ -15,19 +15,13 @@ import { Chalk, type ChalkInstance } from 'chalk'; import Table from 'cli-table3'; import { Command, Option } from 'clipanion'; import { startCase } from 'lodash'; -import { DateTime } from 'luxon'; import fs from 'node:fs/promises'; import { type LiteralUnion } from 'type-fest'; import { inspect } from 'util'; import xlsx from 'xlsx'; -import { - type EnhancedResource, - firstOr, - type ID, - Role, - type Session, -} from '~/common'; +import { type EnhancedResource, firstOr, Role } from '~/common'; import { searchCamelCase } from '~/common/search-camel-case'; +import { Identity } from '~/core/authentication'; import { InjectableCommand } from '~/core/cli'; import { type ResourceLike, ResourcesHost } from '~/core/resources'; import { @@ -45,6 +39,7 @@ type AnyResource = EnhancedResource; @Injectable() export class PolicyDumper { constructor( + private readonly identity: Identity, private readonly resources: ResourcesHost, private readonly executor: PolicyExecutor, ) {} @@ -173,51 +168,45 @@ export class PolicyDumper { resource: AnyResource, options: { props: boolean | ReadonlySet }, ): DumpedRow[] { - const session: Session = { - token: 'system', - issuedAt: DateTime.now(), - userId: 'anonymous' as ID, - anonymous: false, - roles: [`global:${role}`], - }; - const resolve = (action: string, prop?: string) => - this.executor.resolve({ - session, - resource, - calculatedAsCondition: true, - optimizeConditions: true, - action, - prop, - }); - return [ - { - role, - resource, - edge: undefined, - ...mapValues.fromList(ResourceAction, (action) => resolve(action)) - .asRecord, - }, - ...(options.props !== false - ? ([ - [resource.securedPropsPlusExtra, PropAction], - [resource.childSingleKeys, ChildSingleAction], - [resource.childListKeys, ChildListAction], - ] as const) - : [] - ).flatMap(([set, actions]) => - [...set] - .filter( - (p) => typeof options.props === 'boolean' || options.props.has(p), - ) - .map((prop) => ({ - role, - resource, - edge: prop, - ...mapValues.fromList(actions, (action) => resolve(action, prop)) - .asRecord, - })), - ), - ]; + return this.identity.asRole(role, () => { + const resolve = (action: string, prop?: string) => + this.executor.resolve({ + resource, + calculatedAsCondition: true, + optimizeConditions: true, + action, + prop, + }); + return [ + { + role, + resource, + edge: undefined, + ...mapValues.fromList(ResourceAction, (action) => resolve(action)) + .asRecord, + }, + ...(options.props !== false + ? ([ + [resource.securedPropsPlusExtra, PropAction], + [resource.childSingleKeys, ChildSingleAction], + [resource.childListKeys, ChildListAction], + ] as const) + : [] + ).flatMap(([set, actions]) => + [...set] + .filter( + (p) => typeof options.props === 'boolean' || options.props.has(p), + ) + .map((prop) => ({ + role, + resource, + edge: prop, + ...mapValues.fromList(actions, (action) => resolve(action, prop)) + .asRecord, + })), + ), + ]; + }); } } diff --git a/src/components/workflow/permission.serializer.ts b/src/components/workflow/permission.serializer.ts index 029ee72a8c..3ffdcc123e 100644 --- a/src/components/workflow/permission.serializer.ts +++ b/src/components/workflow/permission.serializer.ts @@ -1,7 +1,6 @@ import { setOf } from '@seedcompany/common'; -import { DateTime } from 'luxon'; -import { type ID, Role, type Session } from '~/common'; -import { type SessionHost } from '../authentication'; +import { type ID, Role } from '~/common'; +import { type Identity } from '~/core/authentication'; import { type Privileges, type UserResourcePrivileges } from '../authorization'; import { Condition } from '../authorization/policy/conditions'; import { type Workflow } from './define-workflow'; @@ -12,18 +11,11 @@ export const transitionPermissionSerializer = ( workflow: W, privileges: Privileges, - sessionHost: SessionHost, + identity: Identity, ) => (transition: W['transition']): readonly SerializedTransitionPermission[] => { const all = [...Role].flatMap((role) => { - const session: Session = { - token: 'system', - issuedAt: DateTime.now(), - userId: 'anonymous' as ID, - anonymous: false, - roles: [`global:${role}`], - }; - return sessionHost.withSession(session, () => { + return identity.asRole(role, () => { const p = privileges.for(workflow.eventResource); const readEvent = resolve(p, 'read', transition.key); const execute = resolve(p, 'create', transition.key); diff --git a/src/components/workflow/workflow.service.ts b/src/components/workflow/workflow.service.ts index 05074d954d..522b1ba737 100644 --- a/src/components/workflow/workflow.service.ts +++ b/src/components/workflow/workflow.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { type Nil } from '@seedcompany/common'; import { type ID, UnauthorizedException } from '~/common'; -import { SessionHost } from '../authentication'; +import { Identity } from '~/core/authentication'; import { Privileges } from '../authorization'; import { MissingContextException } from '../authorization/policy/conditions'; import { type Workflow } from './define-workflow'; @@ -20,7 +20,7 @@ export const WorkflowService = (workflow: () => W) => { @Injectable() abstract class WorkflowServiceClass { @Inject() protected readonly privileges: Privileges; - @Inject() protected readonly sessionHost: SessionHost; + @Inject() protected readonly identity: Identity; protected readonly workflow: W; constructor() { @@ -143,7 +143,7 @@ export const WorkflowService = (workflow: () => W) => { transitionPermissionSerializer( this.workflow, this.privileges, - this.sessionHost, + this.identity, ), ); } diff --git a/src/core/authentication/identity.service.ts b/src/core/authentication/identity.service.ts index bc04ffa838..eb54efab2f 100644 --- a/src/core/authentication/identity.service.ts +++ b/src/core/authentication/identity.service.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import type { ID } from '~/common'; +import type { ID, Role } from '~/common'; import { type AuthenticationService } from '../../components/authentication'; import { SessionHost } from '../../components/authentication/session.host'; @@ -29,4 +29,11 @@ export class Identity { async asUser(user: ID<'User'>, fn: () => Promise): Promise { return await this.auth.asUser(user, fn); } + + /** + * Run this function with the current user as an ephemeral one this role + */ + asRole(role: Role, fn: () => R): R { + return this.auth.asRole(role, fn); + } } From de19274f910b2aa03130e1d5254bde8dd325a338 Mon Sep 17 00:00:00 2001 From: Carson Full Date: Mon, 19 May 2025 11:51:24 -0500 Subject: [PATCH 05/12] Create `Identity.readyForCli` for data loaders This should be replaced with something else later. Other entry points (migrations, etc) should declare something explicit. --- src/core/authentication/identity.service.ts | 8 ++++++++ src/core/resources/resource.loader.ts | 17 ++++++++--------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/core/authentication/identity.service.ts b/src/core/authentication/identity.service.ts index eb54efab2f..2b4a58a8e7 100644 --- a/src/core/authentication/identity.service.ts +++ b/src/core/authentication/identity.service.ts @@ -36,4 +36,12 @@ export class Identity { asRole(role: Role, fn: () => R): R { return this.auth.asRole(role, fn); } + + /** + * @deprecated probably replace this with something more explicit. + */ + async readyForCli() { + // Ensure the default root session is ready to go for data loaders + await this.auth.lazySessionForRootUser(); + } } diff --git a/src/core/resources/resource.loader.ts b/src/core/resources/resource.loader.ts index 7db25de360..d9d34fe4ba 100644 --- a/src/core/resources/resource.loader.ts +++ b/src/core/resources/resource.loader.ts @@ -1,11 +1,11 @@ -import { Inject, Injectable, type Type } from '@nestjs/common'; +import { Injectable, type Type } from '@nestjs/common'; import { DataLoaderContext, type DataLoaderStrategy, } from '@seedcompany/data-loader'; import type { ConditionalKeys, Merge, ValueOf } from 'type-fest'; import { type ID, type Many, type ObjectView, ServerException } from '~/common'; -import type { AuthenticationService } from '../../components/authentication'; +import { Identity } from '../authentication'; import { ConfigService } from '../config/config.service'; import { type BaseNode } from '../database/results'; import { GqlContextHost } from '../graphql'; @@ -44,7 +44,7 @@ export class ResourceLoader { private readonly loaderRegistry: ResourceLoaderRegistry, private readonly contextHost: GqlContextHost, private readonly config: ConfigService, - @Inject('AUTHENTICATION') private readonly auth: AuthenticationService & {}, + private readonly identity: Identity, private readonly loaderContext: DataLoaderContext, private readonly resourceResolver: ResourceResolver, ) {} @@ -111,13 +111,12 @@ export class ResourceLoader { type: Type>, ) { if (this.config.isCli) { - // Ensure the default root session is ready to go for data loaders - await this.auth.lazySessionForRootUser(); + await this.identity.readyForCli(); } - return await this.loaderContext.getLoader( - type, - this.config.isCli ? CLI_CONTEXT_ID : this.contextHost.context, - ); + const context = this.config.isCli + ? CLI_CONTEXT_ID + : this.contextHost.context; + return await this.loaderContext.getLoader(type, context); } private findLoaderFactory(type: Many) { From 79b344cddd6afb8dc2617ef14ade1a0d572653f0 Mon Sep 17 00:00:00 2001 From: Carson Full Date: Mon, 19 May 2025 12:07:09 -0500 Subject: [PATCH 06/12] Validate SystemNotification creation with policies --- .../system-notification.resolver.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/components/notification-system/system-notification.resolver.ts b/src/components/notification-system/system-notification.resolver.ts index ee1387ec6e..a5c81d800d 100644 --- a/src/components/notification-system/system-notification.resolver.ts +++ b/src/components/notification-system/system-notification.resolver.ts @@ -6,9 +6,8 @@ import { ObjectType, Resolver, } from '@nestjs/graphql'; -import { LoggedInSession, type Session, UnauthorizedException } from '~/common'; import { MarkdownScalar } from '~/common/markdown.scalar'; -import { isAdmin } from '~/common/session'; +import { Privileges } from '../authorization'; import { NotificationService } from '../notifications'; import { SystemNotification } from './system-notification.dto'; @@ -23,17 +22,16 @@ export class SystemNotificationCreationOutput { @Resolver(SystemNotification) export class SystemNotificationResolver { - constructor(private readonly notifications: NotificationService) {} + constructor( + private readonly notifications: NotificationService, + private readonly privileges: Privileges, + ) {} @Mutation(() => SystemNotificationCreationOutput) async createSystemNotification( @Args({ name: 'message', type: () => MarkdownScalar }) message: string, - @LoggedInSession() session: Session, ): Promise { - if (!isAdmin(session)) { - throw new UnauthorizedException(); - } - + this.privileges.for(SystemNotification).verifyCan('create'); return await this.notifications.create(SystemNotification, null, { message, }); From e87927b971e86c7e0bade8101559143ff44787b6 Mon Sep 17 00:00:00 2001 From: Carson Full Date: Mon, 19 May 2025 12:08:12 -0500 Subject: [PATCH 07/12] Move `isSelf` & `isAdmin` checks into `Identity` --- src/common/session.ts | 9 ----- src/components/comments/comment.service.ts | 4 +-- .../periodic-report.resolver.ts | 23 ++++++------- .../periodic-report.service.ts | 2 ++ src/components/post/post.service.ts | 7 ++++ src/components/post/postable.resolver.ts | 14 +------- src/components/project/project.service.ts | 8 ++--- .../user/education/education.service.ts | 3 +- .../unavailability/unavailability.service.ts | 3 +- src/core/authentication/identity.service.ts | 34 +++++++++++++++++++ 10 files changed, 61 insertions(+), 46 deletions(-) diff --git a/src/common/session.ts b/src/common/session.ts index 571d76215c..78a46b35c1 100644 --- a/src/common/session.ts +++ b/src/common/session.ts @@ -6,7 +6,6 @@ import { } from '@nestjs/common'; import { CONTROLLER_WATERMARK } from '@nestjs/common/constants.js'; import { Context } from '@nestjs/graphql'; -import { uniq } from 'lodash'; import { type DateTime } from 'luxon'; import { Identity } from '~/core/authentication'; import { type ScopedRole } from '../components/authorization/dto'; @@ -70,11 +69,3 @@ export const AnonSession = const SessionWatermark: ParameterDecorator = (target, key) => Reflect.defineMetadata('SESSION_WATERMARK', true, target.constructor, key!); - -export const addScope = (session: Session, scope?: ScopedRole[]) => ({ - ...session, - roles: uniq([...session.roles, ...(scope ?? [])]), -}); - -export const isAdmin = (session: Session) => - session.roles.includes('global:Administrator'); diff --git a/src/components/comments/comment.service.ts b/src/components/comments/comment.service.ts index 70442df3d9..ef5a1e7487 100644 --- a/src/components/comments/comment.service.ts +++ b/src/components/comments/comment.service.ts @@ -11,7 +11,6 @@ import { ServerException, type UnsecuredDto, } from '~/common'; -import { isAdmin } from '~/common/session'; import { ResourceLoader, ResourcesHost } from '~/core'; import { Identity } from '~/core/authentication'; import { type BaseNode, isBaseNode } from '~/core/database/results'; @@ -118,12 +117,11 @@ export class CommentService { } secureThread(thread: UnsecuredDto): CommentThread { - const session = this.identity.current; return { ...thread, firstComment: this.secureComment(thread.firstComment), latestComment: this.secureComment(thread.latestComment), - canDelete: thread.creator === session.userId || isAdmin(session), + canDelete: this.identity.isSelf(thread.creator) || this.identity.isAdmin, }; } diff --git a/src/components/periodic-report/periodic-report.resolver.ts b/src/components/periodic-report/periodic-report.resolver.ts index 8cf515ed65..a554e65bce 100644 --- a/src/components/periodic-report/periodic-report.resolver.ts +++ b/src/components/periodic-report/periodic-report.resolver.ts @@ -6,15 +6,9 @@ import { ResolveField, Resolver, } from '@nestjs/graphql'; -import { - AnonSession, - CalendarDate, - ListArg, - type Session, - UnauthorizedException, -} from '~/common'; -import { isAdmin } from '~/common/session'; +import { CalendarDate, ListArg, UnauthorizedException } from '~/common'; import { Loader, type LoaderOf } from '~/core'; +import { Identity } from '~/core/authentication'; import { type IdsAndView, IdsAndViewArg } from '../changeset/dto'; import { FileNodeLoader, resolveDefinedFile } from '../file'; import { SecuredFile } from '../file/dto'; @@ -30,7 +24,10 @@ import { PeriodicReportService } from './periodic-report.service'; @Resolver(IPeriodicReport) export class PeriodicReportResolver { - constructor(private readonly service: PeriodicReportService) {} + constructor( + private readonly identity: Identity, + private readonly service: PeriodicReportService, + ) {} @Query(() => IPeriodicReport, { description: 'Read a periodic report by id.', @@ -46,14 +43,16 @@ export class PeriodicReportResolver { description: 'List of periodic reports', }) async periodicReports( - @AnonSession() session: Session, @ListArg(PeriodicReportListInput) input: PeriodicReportListInput, @Loader(ReportLoader) loader: LoaderOf, ): Promise { - // Only let admins do this for now. - if (!isAdmin(session)) { + // Only let admins do this for now, since it spans across projects, + // and the db query may not be filtering correctly. + // TODO update list query to filter by auth + if (!this.identity.isAdmin) { throw new UnauthorizedException(); } + const list = await this.service.list(input); loader.primeAll(list.items); return list; diff --git a/src/components/periodic-report/periodic-report.service.ts b/src/components/periodic-report/periodic-report.service.ts index 6fcc864745..73f3e28f79 100644 --- a/src/components/periodic-report/periodic-report.service.ts +++ b/src/components/periodic-report/periodic-report.service.ts @@ -10,6 +10,7 @@ import { type UnsecuredDto, } from '~/common'; import { HandleIdLookup, IEventBus, ILogger, Logger } from '~/core'; +import { Identity } from '~/core/authentication'; import { type Variable } from '~/core/database/query'; import { Privileges } from '../authorization'; import { FileService } from '../file'; @@ -35,6 +36,7 @@ export class PeriodicReportService { private readonly files: FileService, @Logger('periodic:report:service') private readonly logger: ILogger, private readonly eventBus: IEventBus, + private readonly identity: Identity, private readonly privileges: Privileges, private readonly repo: PeriodicReportRepository, ) {} diff --git a/src/components/post/post.service.ts b/src/components/post/post.service.ts index e531bbd1ed..a7f4694d0f 100644 --- a/src/components/post/post.service.ts +++ b/src/components/post/post.service.ts @@ -12,6 +12,7 @@ import { type UnsecuredDto, } from '~/common'; import { ILogger, Logger, ResourceLoader, ResourcesHost } from '~/core'; +import { Identity } from '~/core/authentication'; import { type BaseNode, isBaseNode } from '~/core/database/results'; import { Privileges } from '../authorization'; import { type CreatePost, Post, Postable, type UpdatePost } from './dto'; @@ -24,6 +25,7 @@ type PostableRef = ID | BaseNode | ConcretePostable; @Injectable() export class PostService { constructor( + private readonly identity: Identity, private readonly privileges: Privileges, private readonly repo: PostRepository, private readonly resources: ResourceLoader, @@ -90,6 +92,11 @@ export class PostService { parent: ConcretePostable & Resource, input: PostListInput, ): Promise { + // TODO move to auth policy + if (this.identity.isAnonymous) { + return SecuredList.Redacted; + } + const perms = await this.getPermissionsFromPostable(parent); if (!perms.can('read')) { diff --git a/src/components/post/postable.resolver.ts b/src/components/post/postable.resolver.ts index 78182ac5c7..c1b115fb91 100644 --- a/src/components/post/postable.resolver.ts +++ b/src/components/post/postable.resolver.ts @@ -1,12 +1,6 @@ import { Info, Parent, ResolveField, Resolver } from '@nestjs/graphql'; import { type GraphQLResolveInfo } from 'graphql'; -import { - AnonSession, - ListArg, - type Resource, - SecuredList, - type Session, -} from '~/common'; +import { ListArg, type Resource } from '~/common'; import { Loader, type LoaderOf } from '~/core'; import { Postable } from './dto'; import { PostListInput, SecuredPostList } from './dto/list-posts.dto'; @@ -24,14 +18,8 @@ export class PostableResolver { @Info() info: GraphQLResolveInfo & {}, @Parent() parent: Postable & Resource, @ListArg(PostListInput) input: PostListInput, - @AnonSession() session: Session, @Loader(PostLoader) posts: LoaderOf, ): Promise { - // TODO move to auth policy - if (session.anonymous) { - return SecuredList.Redacted; - } - const list = await this.service.securedList( { ...parent, diff --git a/src/components/project/project.service.ts b/src/components/project/project.service.ts index 41d7d9b4a4..1b4eb1a6b2 100644 --- a/src/components/project/project.service.ts +++ b/src/components/project/project.service.ts @@ -20,7 +20,6 @@ import { UnauthorizedException, type UnsecuredDto, } from '~/common'; -import { isAdmin } from '~/common/session'; import { HandleIdLookup, IEventBus } from '~/core'; import { Identity } from '~/core/authentication'; import { Transactional } from '~/core/database'; @@ -137,8 +136,7 @@ export class ProjectService { ); // Only allow admins to specify department IDs - const session = this.identity.current; - if (input.departmentId && !isAdmin(session.impersonator ?? session)) { + if (input.departmentId && !this.identity.isImpersonatorAdmin) { throw UnauthorizedException.fromPrivileges( 'edit', undefined, @@ -158,6 +156,7 @@ export class ProjectService { RequiredWhen.verify(IProject, project); // Add creator to the project team with their global roles + const session = this.identity.current; await this.projectMembers.create( { userId: session.userId, @@ -252,10 +251,9 @@ export class ProjectService { ); // Only allow admins to specify department IDs - const session = this.identity.current; if ( input.departmentId !== undefined && - !isAdmin(session.impersonator ?? session) + !this.identity.isImpersonatorAdmin ) { throw UnauthorizedException.fromPrivileges( 'edit', diff --git a/src/components/user/education/education.service.ts b/src/components/user/education/education.service.ts index c2d8638ba0..d89cdee9db 100644 --- a/src/components/user/education/education.service.ts +++ b/src/components/user/education/education.service.ts @@ -47,8 +47,7 @@ export class EducationService { const result = await this.repo.getUserIdByEducation(input.id); const changes = this.repo.getActualChanges(ed, input); // TODO move this condition into policies - const session = this.identity.current; - if (result.id !== session.userId) { + if (!this.identity.isSelf(result.id)) { this.privileges.for(Education, ed).verifyChanges(changes); } diff --git a/src/components/user/unavailability/unavailability.service.ts b/src/components/user/unavailability/unavailability.service.ts index 76f0674478..1d547bcb77 100644 --- a/src/components/user/unavailability/unavailability.service.ts +++ b/src/components/user/unavailability/unavailability.service.ts @@ -46,8 +46,7 @@ export class UnavailabilityService { const result = await this.repo.getUserIdByUnavailability(input.id); const changes = this.repo.getActualChanges(unavailability, input); // TODO move this condition into policies - const session = this.identity.current; - if (result.id !== session.userId) { + if (!this.identity.isSelf(result.id)) { this.privileges .for(Unavailability, unavailability) .verifyChanges(changes); diff --git a/src/core/authentication/identity.service.ts b/src/core/authentication/identity.service.ts index 2b4a58a8e7..ec35b1d44b 100644 --- a/src/core/authentication/identity.service.ts +++ b/src/core/authentication/identity.service.ts @@ -26,6 +26,40 @@ export class Identity { return this.sessionHost.currentIfInCtx; } + /** + * Is the current user an admin? + * + * Not the best API, use is discouraged. + * Prefer using Auth Policies / {@link Privileges}`.for.can()` + */ + get isAdmin() { + return this.current.roles.includes('global:Administrator'); + } + + /** + * Is the current user (or impersonator) an admin? + * This ignores impersonation. + * + * Not the best API, use is discouraged. + * Prefer using Auth Policies / {@link Privileges}`.for.can()` + */ + get isImpersonatorAdmin() { + const session = this.current; + return (session.impersonator ?? session).roles.includes( + 'global:Administrator', + ); + } + + /** + * Is this the ID of the current user? + * + * Not the best API, use is discouraged. + * Prefer using Auth Policies / {@link Privileges}`.for.can()` + */ + isSelf(id: ID<'User'>) { + return id === this.current.userId; + } + async asUser(user: ID<'User'>, fn: () => Promise): Promise { return await this.auth.asUser(user, fn); } From d3ccbb3248ca42d229935735e16333d4a387bddb Mon Sep 17 00:00:00 2001 From: Carson Full Date: Mon, 19 May 2025 15:34:01 -0500 Subject: [PATCH 08/12] Fix some cyclic dependencies These were chaos. NodeJS just exited, no error or logs. This crash happened in `Injector.loadInstance` on the returning the `instanceHost.donePromise`. I believe the cycle check right above it should have caught this, but it didn't. I'm clueless on how node just exited too... somehow a deadlock or empty event loop somehow? --- src/components/file/file.service.ts | 3 ++- src/core/authentication/identity.service.ts | 4 ++-- src/core/database/database.service.ts | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/file/file.service.ts b/src/components/file/file.service.ts index ef61865e05..b747e99f68 100644 --- a/src/components/file/file.service.ts +++ b/src/components/file/file.service.ts @@ -2,7 +2,7 @@ import { GetObjectCommand as GetObject, PutObjectCommand as PutObject, } from '@aws-sdk/client-s3'; -import { Injectable } from '@nestjs/common'; +import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { bufferFromStream, cleanJoin, type Nil } from '@seedcompany/common'; import { fileTypeFromBuffer } from 'file-type'; import { intersection } from 'lodash'; @@ -60,6 +60,7 @@ export class FileService { private readonly repo: FileRepository, private readonly rollbacks: RollbackManager, private readonly config: ConfigService, + @Inject(forwardRef(() => MediaService)) private readonly mediaService: MediaService, private readonly eventBus: IEventBus, @Logger('file:service') private readonly logger: ILogger, diff --git a/src/core/authentication/identity.service.ts b/src/core/authentication/identity.service.ts index ec35b1d44b..715d2eb048 100644 --- a/src/core/authentication/identity.service.ts +++ b/src/core/authentication/identity.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { forwardRef, Inject, Injectable } from '@nestjs/common'; import type { ID, Role } from '~/common'; import { type AuthenticationService } from '../../components/authentication'; import { SessionHost } from '../../components/authentication/session.host'; @@ -9,7 +9,7 @@ import { SessionHost } from '../../components/authentication/session.host'; @Injectable() export class Identity { constructor( - @Inject('AUTHENTICATION') + @Inject(forwardRef(() => 'AUTHENTICATION')) private readonly auth: AuthenticationService & {}, private readonly sessionHost: SessionHost, ) {} diff --git a/src/core/database/database.service.ts b/src/core/database/database.service.ts index 3c9c76fdc6..2988707939 100644 --- a/src/core/database/database.service.ts +++ b/src/core/database/database.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { entries, mapKeys } from '@seedcompany/common'; import { Connection, node, type Query, relation } from 'cypher-query-builder'; import { LazyGetter } from 'lazy-get-decorator'; @@ -64,6 +64,7 @@ export class DatabaseService { constructor( private readonly db: Connection, private readonly config: ConfigService, + @Inject(forwardRef(() => Identity)) private readonly identity: Identity, private readonly shutdown$: ShutdownHook, @Logger('database:service') private readonly logger: ILogger, From 14b8a78e29423f9d760099760e520fb4a713a4f4 Mon Sep 17 00:00:00 2001 From: Carson Full Date: Mon, 19 May 2025 12:24:07 -0500 Subject: [PATCH 09/12] Add `Identity.isAnonymous` for future use --- src/core/authentication/identity.service.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/core/authentication/identity.service.ts b/src/core/authentication/identity.service.ts index 715d2eb048..444d170b73 100644 --- a/src/core/authentication/identity.service.ts +++ b/src/core/authentication/identity.service.ts @@ -26,6 +26,16 @@ export class Identity { return this.sessionHost.currentIfInCtx; } + /** + * Is the current requestor anonymous (not logged in)? + * + * Not the best API, use is discouraged. + * Prefer using Auth Policies / {@link Privileges}`.for.can()` + */ + get isAnonymous() { + return this.current.anonymous; + } + /** * Is the current user an admin? * From 30402ce280531c8f9388c79e72279366b232fa8b Mon Sep 17 00:00:00 2001 From: Carson Full Date: Mon, 19 May 2025 17:34:19 -0500 Subject: [PATCH 10/12] Add `Identity.verifyLoggedIn` --- src/common/session.ts | 11 +---------- src/components/authentication/session.interceptor.ts | 5 +++-- src/components/comments/comment-thread.resolver.ts | 8 ++++---- src/components/file/file-url.controller.ts | 5 +++-- src/core/authentication/identity.service.ts | 11 ++++++++++- 5 files changed, 21 insertions(+), 19 deletions(-) diff --git a/src/common/session.ts b/src/common/session.ts index 78a46b35c1..899ed7e27e 100644 --- a/src/common/session.ts +++ b/src/common/session.ts @@ -9,7 +9,6 @@ import { Context } from '@nestjs/graphql'; import { type DateTime } from 'luxon'; import { Identity } from '~/core/authentication'; import { type ScopedRole } from '../components/authorization/dto'; -import { UnauthenticatedException } from './exceptions'; import { type ID } from './id-field'; export interface Session { @@ -32,13 +31,6 @@ export interface Session { }; } -export function loggedInSession(session: Session): Session { - if (session.anonymous) { - throw new UnauthenticatedException('User is not logged in'); - } - return session; -} - @Injectable() export class SessionPipe implements PipeTransform { constructor(private readonly identity: Identity) {} @@ -49,8 +41,7 @@ export class SessionPipe implements PipeTransform { } /** @deprecated */ -export const LoggedInSession = () => - AnonSession({ transform: loggedInSession }); +export const LoggedInSession = () => AnonSession(); /** @deprecated */ export const AnonSession = diff --git a/src/components/authentication/session.interceptor.ts b/src/components/authentication/session.interceptor.ts index 9780fa1def..5680cb5de9 100644 --- a/src/components/authentication/session.interceptor.ts +++ b/src/components/authentication/session.interceptor.ts @@ -21,8 +21,8 @@ import { type Session, UnauthenticatedException, } from '~/common'; -import { loggedInSession as verifyLoggedIn } from '~/common/session'; import { ConfigService } from '~/core'; +import { Identity } from '~/core/authentication'; import { GlobalHttpHook, type IRequest } from '~/core/http'; import { rolesForScope } from '../authorization/dto'; import { Anonymous } from './anonymous.decorator'; @@ -36,6 +36,7 @@ export class SessionInterceptor implements NestInterceptor { private readonly auth: AuthenticationService & {}, private readonly config: ConfigService, private readonly sessionHost: SessionHost, + private readonly identity: Identity, ) {} private readonly sessionByRequest = new AsyncLocalStorage< @@ -85,7 +86,7 @@ export class SessionInterceptor implements NestInterceptor { Anonymous.get(executionContext.getClass()) ?? !isMutation; if (!allowAnonymous && session) { - verifyLoggedIn(session); + this.identity.verifyLoggedIn(); } return next.handle(); diff --git a/src/components/comments/comment-thread.resolver.ts b/src/components/comments/comment-thread.resolver.ts index a7df2e41eb..5a1a9d43e9 100644 --- a/src/components/comments/comment-thread.resolver.ts +++ b/src/components/comments/comment-thread.resolver.ts @@ -1,8 +1,8 @@ import { Args, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql'; import { stripIndent } from 'common-tags'; -import { AnonSession, type ID, IdArg, ListArg, type Session } from '~/common'; -import { loggedInSession as verifyLoggedIn } from '~/common/session'; +import { type ID, IdArg, ListArg } from '~/common'; import { Loader, type LoaderOf, ResourceLoader } from '~/core'; +import { Identity } from '~/core/authentication'; import { UserLoader } from '../user'; import { User } from '../user/dto'; import { CommentThreadLoader } from './comment-thread.loader'; @@ -23,6 +23,7 @@ export class CommentThreadResolver { constructor( private readonly service: CommentService, private readonly resources: ResourceLoader, + private readonly identity: Identity, ) {} @Query(() => CommentThread, { @@ -41,11 +42,10 @@ export class CommentThreadResolver { async commentThreads( @IdArg({ name: 'resource' }) resourceId: ID, @ListArg(CommentThreadListInput) input: CommentThreadListInput, - @AnonSession() session: Session, @Loader(CommentThreadLoader) commentThreads: LoaderOf, ): Promise { // TODO move to auth policy - verifyLoggedIn(session); + this.identity.verifyLoggedIn(); const resource = await this.service.loadCommentable(resourceId); const list = await this.service.listThreads(resource, input); commentThreads.primeAll(list.items); diff --git a/src/components/file/file-url.controller.ts b/src/components/file/file-url.controller.ts index f19c96d02c..196a5e6102 100644 --- a/src/components/file/file-url.controller.ts +++ b/src/components/file/file-url.controller.ts @@ -10,7 +10,7 @@ import { Response, } from '@nestjs/common'; import { type ID } from '~/common'; -import { loggedInSession as verifyLoggedIn } from '~/common/session'; +import { Identity } from '~/core/authentication'; import { HttpAdapter, type IRequest, type IResponse } from '~/core/http'; import { SessionInterceptor } from '../authentication/session.interceptor'; import { FileService } from './file.service'; @@ -23,6 +23,7 @@ export class FileUrlController { @Inject(forwardRef(() => FileService)) private readonly files: FileService & {}, private readonly sessionHost: SessionInterceptor, + private readonly identity: Identity, private readonly http: HttpAdapter, ) {} @@ -37,7 +38,7 @@ export class FileUrlController { if (!node.public) { const session = await this.sessionHost.hydrateSession({ request }); - verifyLoggedIn(session); + this.identity.verifyLoggedIn(session); } // TODO authorization using session diff --git a/src/core/authentication/identity.service.ts b/src/core/authentication/identity.service.ts index 444d170b73..337dc7f90d 100644 --- a/src/core/authentication/identity.service.ts +++ b/src/core/authentication/identity.service.ts @@ -1,5 +1,5 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; -import type { ID, Role } from '~/common'; +import { type ID, type Role, UnauthenticatedException } from '~/common'; import { type AuthenticationService } from '../../components/authentication'; import { SessionHost } from '../../components/authentication/session.host'; @@ -36,6 +36,15 @@ export class Identity { return this.current.anonymous; } + /** + * Manually verify the current requestor is logged in. + */ + verifyLoggedIn(session?: Identity['current']) { + if ((session ?? this.current).anonymous) { + throw new UnauthenticatedException('User is not logged in'); + } + } + /** * Is the current user an admin? * From 9d5a7fabe194d2b972834a55522b9f94cfb85223 Mon Sep 17 00:00:00 2001 From: Carson Full Date: Mon, 19 May 2025 17:25:14 -0500 Subject: [PATCH 11/12] Migrate remaining session decorations to ALS service --- .../authentication/extra-info.resolver.ts | 9 ++------- .../authentication/login.resolver.ts | 6 ++++-- .../changeset/changeset-aware.resolver.ts | 19 +++++-------------- .../changeset/changeset.resolver.ts | 13 ++++--------- .../comments/commentable.resolver.ts | 11 ++++++----- src/components/file/file-node.resolver.ts | 6 +----- .../notifications/notification.resolver.ts | 11 +++++++---- src/components/pin/pin.resolver.ts | 18 +++++++----------- .../create-product-connection.resolver.ts | 11 +++++++---- .../financial-approver.resolver.ts | 6 +++--- .../user/assignable-roles.resolver.ts | 4 ++-- src/components/user/user.resolver.ts | 11 ++++------- 12 files changed, 52 insertions(+), 73 deletions(-) diff --git a/src/components/authentication/extra-info.resolver.ts b/src/components/authentication/extra-info.resolver.ts index 3f9d359f44..2a1bae8c32 100644 --- a/src/components/authentication/extra-info.resolver.ts +++ b/src/components/authentication/extra-info.resolver.ts @@ -1,11 +1,6 @@ import { ResolveField, Resolver } from '@nestjs/graphql'; import { mapValues } from '@seedcompany/common'; -import { - type AbstractClassType, - AnonSession, - EnhancedResource, - type Session, -} from '~/common'; +import { type AbstractClassType, EnhancedResource } from '~/common'; import { Privileges } from '../authorization'; import { BetaFeatures } from '../authorization/dto/beta-features.dto'; import { LoginOutput, RegisterOutput, SessionOutput } from './dto'; @@ -16,7 +11,7 @@ function AuthExtraInfoResolver(concreteClass: AbstractClassType) { constructor(private readonly privileges: Privileges) {} @ResolveField(() => BetaFeatures) - betaFeatures(@AnonSession() session: Session): BetaFeatures { + betaFeatures(): BetaFeatures { const privileges = this.privileges.for(BetaFeatures); const { props } = EnhancedResource.of(BetaFeatures); return mapValues.fromList([...props], (prop) => diff --git a/src/components/authentication/login.resolver.ts b/src/components/authentication/login.resolver.ts index 6c8f2a230c..0730196018 100644 --- a/src/components/authentication/login.resolver.ts +++ b/src/components/authentication/login.resolver.ts @@ -6,7 +6,6 @@ import { Resolver, } from '@nestjs/graphql'; import { stripIndent } from 'common-tags'; -import { AnonSession, type Session } from '~/common'; import { Loader, type LoaderOf } from '~/core'; import { Privileges } from '../authorization'; import { Power } from '../authorization/dto'; @@ -15,11 +14,13 @@ import { User } from '../user/dto'; import { Anonymous } from './anonymous.decorator'; import { AuthenticationService } from './authentication.service'; import { LoginInput, LoginOutput, LogoutOutput } from './dto'; +import { SessionHost } from './session.host'; @Resolver(LoginOutput) export class LoginResolver { constructor( private readonly authentication: AuthenticationService, + private readonly sessionHost: SessionHost, private readonly privileges: Privileges, ) {} @@ -43,7 +44,8 @@ export class LoginResolver { `, }) @Anonymous() - async logout(@AnonSession() session: Session): Promise { + async logout(): Promise { + const session = this.sessionHost.current; await this.authentication.logout(session.token); await this.authentication.refreshCurrentSession(); // ensure session data is fresh return { success: true }; diff --git a/src/components/changeset/changeset-aware.resolver.ts b/src/components/changeset/changeset-aware.resolver.ts index fc7dce74e1..c07f996fd0 100644 --- a/src/components/changeset/changeset-aware.resolver.ts +++ b/src/components/changeset/changeset-aware.resolver.ts @@ -1,13 +1,8 @@ import { Info, Parent, ResolveField, Resolver } from '@nestjs/graphql'; import { stripIndent } from 'common-tags'; -import { - AnonSession, - Fields, - IsOnlyId, - Resource, - type Session, -} from '~/common'; +import { Fields, IsOnlyId, Resource } from '~/common'; import { ResourceLoader, ResourceResolver } from '~/core'; +import { Identity } from '~/core/authentication'; import { ChangesetResolver } from './changeset.resolver'; import { Changeset, ChangesetAware, ChangesetDiff } from './dto'; @@ -15,6 +10,7 @@ import { Changeset, ChangesetAware, ChangesetDiff } from './dto'; export class ChangesetAwareResolver { constructor( private readonly resources: ResourceLoader, + private readonly identity: Identity, private readonly resourceResolver: ResourceResolver, private readonly changesetResolver: ChangesetResolver, ) {} @@ -55,10 +51,9 @@ export class ChangesetAwareResolver { }) async changesetDiff( @Parent() object: ChangesetAware, - @AnonSession() session: Session, ): Promise { // TODO move to auth policy - if (session.anonymous) { + if (this.identity.isAnonymous) { return null; } @@ -66,11 +61,7 @@ export class ChangesetAwareResolver { if (!changeset) { return null; } - const diff = await this.changesetResolver.difference( - changeset, - session, - object.id, - ); + const diff = await this.changesetResolver.difference(changeset, object.id); return diff; } } diff --git a/src/components/changeset/changeset.resolver.ts b/src/components/changeset/changeset.resolver.ts index 4cab53eb11..8b5ff4a1bd 100644 --- a/src/components/changeset/changeset.resolver.ts +++ b/src/components/changeset/changeset.resolver.ts @@ -1,12 +1,7 @@ import { Parent, Query, ResolveField, Resolver } from '@nestjs/graphql'; -import { - AnonSession, - type ID, - IdArg, - type ObjectView, - type Session, -} from '~/common'; +import { type ID, IdArg, type ObjectView } from '~/common'; import { ResourceLoader } from '~/core'; +import { Identity } from '~/core/authentication'; import { type BaseNode } from '~/core/database/results'; import { ChangesetRepository } from './changeset.repository'; import { Changeset, ChangesetDiff, type ResourceChange } from './dto'; @@ -16,6 +11,7 @@ export class ChangesetResolver { constructor( private readonly repo: ChangesetRepository, private readonly resources: ResourceLoader, + private readonly identity: Identity, ) {} @Query(() => Changeset) @@ -28,7 +24,6 @@ export class ChangesetResolver { }) async difference( @Parent() changeset: Changeset, - @AnonSession() session: Session, @IdArg({ name: 'resource', nullable: true, @@ -38,7 +33,7 @@ export class ChangesetResolver { parent?: ID, ): Promise { // TODO move to auth policy - if (session.anonymous) { + if (this.identity.isAnonymous) { return { added: [], removed: [], changed: [] }; } diff --git a/src/components/comments/commentable.resolver.ts b/src/components/comments/commentable.resolver.ts index eb5f1fbfe9..9c42dff448 100644 --- a/src/components/comments/commentable.resolver.ts +++ b/src/components/comments/commentable.resolver.ts @@ -1,6 +1,5 @@ import { Info, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql'; import { - AnonSession, Fields, type ID, IdArg, @@ -8,16 +7,19 @@ import { ListArg, type Resource, SecuredList, - type Session, } from '~/common'; import { Loader, type LoaderOf } from '~/core'; +import { Identity } from '~/core/authentication'; import { CommentThreadLoader } from './comment-thread.loader'; import { CommentService } from './comment.service'; import { Commentable, CommentThreadList, CommentThreadListInput } from './dto'; @Resolver(Commentable) export class CommentableResolver { - constructor(private readonly service: CommentService) {} + constructor( + private readonly service: CommentService, + private readonly identity: Identity, + ) {} @Query(() => Commentable, { description: 'Load a commentable resource by ID', @@ -32,12 +34,11 @@ export class CommentableResolver { async commentThreads( @Parent() parent: Commentable & Resource, @ListArg(CommentThreadListInput) input: CommentThreadListInput, - @AnonSession() session: Session, @Loader(CommentThreadLoader) commentThreads: LoaderOf, @Info(Fields, IsOnly(['total'])) onlyTotal: boolean, ) { // TODO move to auth policy - if (session.anonymous) { + if (this.identity.isAnonymous) { return { parent, ...SecuredList.Redacted }; } if (onlyTotal) { diff --git a/src/components/file/file-node.resolver.ts b/src/components/file/file-node.resolver.ts index a230093ce3..12d5cce951 100644 --- a/src/components/file/file-node.resolver.ts +++ b/src/components/file/file-node.resolver.ts @@ -1,6 +1,5 @@ import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; import { stripIndent } from 'common-tags'; -import { AnonSession, type Session } from '~/common'; import { Loader, type LoaderOf } from '~/core'; import { UserLoader } from '../user'; import { User } from '../user/dto'; @@ -33,10 +32,7 @@ export class FileNodeResolver { without having to fetch each parent serially. `, }) - async parents( - @Parent() node: FileNode, - @AnonSession() _session: Session, - ): Promise { + async parents(@Parent() node: FileNode): Promise { return await this.service.getParents(node.id); } diff --git a/src/components/notifications/notification.resolver.ts b/src/components/notifications/notification.resolver.ts index 4ae63fdf91..9e4570b33c 100644 --- a/src/components/notifications/notification.resolver.ts +++ b/src/components/notifications/notification.resolver.ts @@ -1,5 +1,6 @@ import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; -import { AnonSession, ListArg, type Session } from '~/common'; +import { ListArg } from '~/common'; +import { Identity } from '~/core/authentication'; import { MarkNotificationReadArgs, Notification, @@ -10,15 +11,17 @@ import { NotificationServiceImpl } from './notification.service'; @Resolver() export class NotificationResolver { - constructor(private readonly service: NotificationServiceImpl) {} + constructor( + private readonly service: NotificationServiceImpl, + private readonly identity: Identity, + ) {} @Query(() => NotificationList) async notifications( - @AnonSession() session: Session, @ListArg(NotificationListInput) input: NotificationListInput, ): Promise { // TODO move to DB layer? - if (session.anonymous) { + if (this.identity.isAnonymous) { return { items: [], total: 0, totalUnread: 0, hasMore: false }; } return await this.service.list(input); diff --git a/src/components/pin/pin.resolver.ts b/src/components/pin/pin.resolver.ts index 103ed7932a..695f6833d6 100644 --- a/src/components/pin/pin.resolver.ts +++ b/src/components/pin/pin.resolver.ts @@ -1,32 +1,28 @@ import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; -import { - AnonSession, - type ID, - IdArg, - ListArg, - NotImplementedException, - type Session, -} from '~/common'; +import { type ID, IdArg, ListArg, NotImplementedException } from '~/common'; +import { Identity } from '~/core/authentication'; import { PinnedListInput, type PinnedListOutput } from './dto'; import { PinService } from './pin.service'; @Resolver() export class PinResolver { - constructor(readonly pins: PinService) {} + constructor( + private readonly pins: PinService, + private readonly identity: Identity, + ) {} @Query(() => Boolean, { description: 'Returns whether or not the requesting user has pinned the resource ID', }) async isPinned( - @AnonSession() session: Session, @IdArg({ description: 'A resource ID', }) id: ID, ): Promise { // TODO move to DB layer? - if (session.anonymous) { + if (this.identity.isAnonymous) { return false; } return await this.pins.isPinned(id); diff --git a/src/components/product-progress/create-product-connection.resolver.ts b/src/components/product-progress/create-product-connection.resolver.ts index bb5a251deb..9b5655b17e 100644 --- a/src/components/product-progress/create-product-connection.resolver.ts +++ b/src/components/product-progress/create-product-connection.resolver.ts @@ -1,21 +1,24 @@ import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; -import { AnonSession, type Session, Variant } from '~/common'; +import { Variant } from '~/common'; +import { Identity } from '~/core/authentication'; import { CreateProductOutput } from '../product/dto'; import { ProductProgressService } from './product-progress.service'; @Resolver(() => CreateProductOutput) export class ProgressReportCreateProductConnectionResolver { - constructor(private readonly service: ProductProgressService) {} + constructor( + private readonly service: ProductProgressService, + private readonly identity: Identity, + ) {} @ResolveField(() => [Variant], { description: 'All available progress variants for this product', }) async availableVariants( @Parent() { product }: CreateProductOutput, - @AnonSession() session: Session, ): Promise { // TODO move to auth policy - if (session.anonymous) { + if (this.identity.isAnonymous) { return []; } return await this.service.getAvailableVariantsForProduct(product); diff --git a/src/components/project/financial-approver/financial-approver.resolver.ts b/src/components/project/financial-approver/financial-approver.resolver.ts index 2a66cffed9..70d4dd44b7 100644 --- a/src/components/project/financial-approver/financial-approver.resolver.ts +++ b/src/components/project/financial-approver/financial-approver.resolver.ts @@ -6,8 +6,8 @@ import { ResolveField, Resolver, } from '@nestjs/graphql'; -import { AnonSession, type Session } from '~/common'; import { Loader, type LoaderOf } from '~/core'; +import { Identity } from '~/core/authentication'; import { Privileges } from '../../authorization'; import { UserLoader } from '../../user'; import { User } from '../../user/dto'; @@ -20,6 +20,7 @@ export class FinancialApproverResolver { constructor( private readonly repo: FinancialApproverRepository, private readonly privileges: Privileges, + private readonly identity: Identity, ) {} @Query(() => [FinancialApprover]) @@ -30,10 +31,9 @@ export class FinancialApproverResolver { nullable: true, }) types: readonly ProjectType[] | undefined, - @AnonSession() session: Session, ): Promise { // TODO move to auth policy - if (session.anonymous) { + if (this.identity.isAnonymous) { return []; } return await this.repo.read(types); diff --git a/src/components/user/assignable-roles.resolver.ts b/src/components/user/assignable-roles.resolver.ts index 5fe4fa41c5..9d567a9e2d 100644 --- a/src/components/user/assignable-roles.resolver.ts +++ b/src/components/user/assignable-roles.resolver.ts @@ -1,5 +1,5 @@ import { ResolveField, Resolver } from '@nestjs/graphql'; -import { AnonSession, Role, SecuredRoles, type Session } from '~/common'; +import { Role, SecuredRoles } from '~/common'; import { UserService } from './user.service'; @Resolver(SecuredRoles) @@ -10,7 +10,7 @@ export class AssignableRolesResolver { description: 'All of the roles that you have permission to assign to this user', }) - async assignableRoles(@AnonSession() session: Session) { + async assignableRoles() { return [...this.service.getAssignableRoles()]; } } diff --git a/src/components/user/user.resolver.ts b/src/components/user/user.resolver.ts index 0d0787c4bc..d7e36dcf15 100644 --- a/src/components/user/user.resolver.ts +++ b/src/components/user/user.resolver.ts @@ -8,7 +8,6 @@ import { Resolver, } from '@nestjs/graphql'; import { - AnonSession, firstLettersOfWords, type ID, IdArg, @@ -16,9 +15,9 @@ import { ListArg, NotFoundException, ReadAfterCreationFailed, - type Session, } from '~/common'; import { Loader, type LoaderOf } from '~/core'; +import { Identity } from '~/core/authentication'; import { LocationLoader } from '../location'; import { LocationListInput, SecuredLocationList } from '../location/dto'; import { OrganizationLoader } from '../organization'; @@ -72,6 +71,7 @@ export class UserResolver { constructor( private readonly userService: UserService, private readonly timeZoneService: TimeZoneService, + private readonly identity: Identity, ) {} @Query(() => User, { @@ -133,12 +133,9 @@ export class UserResolver { description: 'Returns a user for a given email address', nullable: true, }) - async userByEmail( - @AnonSession() session: Session, - @Args() { email }: CheckEmailArgs, - ): Promise { + async userByEmail(@Args() { email }: CheckEmailArgs): Promise { // TODO move to auth policy? - if (session.anonymous) { + if (this.identity.isAnonymous) { return null; } return await this.userService.getUserByEmailAddress(email); From 64fb40a6bd1945c6105a1a5f8f81a0f0949544da Mon Sep 17 00:00:00 2001 From: Carson Full Date: Mon, 19 May 2025 17:39:50 -0500 Subject: [PATCH 12/12] Drop session decorators --- src/common/index.ts | 2 +- src/common/session.ts | 39 ------------------- .../authentication/authentication.module.ts | 2 - 3 files changed, 1 insertion(+), 42 deletions(-) diff --git a/src/common/index.ts b/src/common/index.ts index cd85b50c95..bbafb1d59b 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -49,7 +49,7 @@ export * from './secured-mapper'; export * from './sensitivity.enum'; export * from './trace-layer'; export * from './util'; -export { type Session, LoggedInSession, AnonSession } from './session'; +export { type Session } from './session'; export * from './types'; export * from './validators'; export * from './name-field'; diff --git a/src/common/session.ts b/src/common/session.ts index 899ed7e27e..dc322866b5 100644 --- a/src/common/session.ts +++ b/src/common/session.ts @@ -1,13 +1,4 @@ -import { - Injectable, - Param, - type PipeTransform, - type Type, -} from '@nestjs/common'; -import { CONTROLLER_WATERMARK } from '@nestjs/common/constants.js'; -import { Context } from '@nestjs/graphql'; import { type DateTime } from 'luxon'; -import { Identity } from '~/core/authentication'; import { type ScopedRole } from '../components/authorization/dto'; import { type ID } from './id-field'; @@ -30,33 +21,3 @@ export interface Session { roles: readonly ScopedRole[]; }; } - -@Injectable() -export class SessionPipe implements PipeTransform { - constructor(private readonly identity: Identity) {} - - transform() { - return this.identity.currentMaybe; - } -} - -/** @deprecated */ -export const LoggedInSession = () => AnonSession(); - -/** @deprecated */ -export const AnonSession = - (...pipes: Array | PipeTransform>): ParameterDecorator => - (...args) => { - Context(SessionPipe, ...pipes)(...args); - process.nextTick(() => { - // Only set this metadata if it's a controller method. - // Waiting for the next tick as class decorators execute after methods. - if (Reflect.getMetadata(CONTROLLER_WATERMARK, args[0].constructor)) { - Param(SessionPipe, ...pipes)(...args); - SessionWatermark(...args); - } - }); - }; - -const SessionWatermark: ParameterDecorator = (target, key) => - Reflect.defineMetadata('SESSION_WATERMARK', true, target.constructor, key!); diff --git a/src/components/authentication/authentication.module.ts b/src/components/authentication/authentication.module.ts index f8a098dd00..ad9be17702 100644 --- a/src/components/authentication/authentication.module.ts +++ b/src/components/authentication/authentication.module.ts @@ -1,6 +1,5 @@ import { forwardRef, Global, Module } from '@nestjs/common'; import { APP_INTERCEPTOR } from '@nestjs/core'; -import { SessionPipe } from '~/common/session'; import { splitDb } from '~/core'; import { Identity } from '~/core/authentication'; import { AuthorizationModule } from '../authorization/authorization.module'; @@ -43,7 +42,6 @@ import { SessionResolver } from './session.resolver'; SessionInterceptor, { provide: APP_INTERCEPTOR, useExisting: SessionInterceptor }, { provide: SessionHost, useClass: SessionHostImpl }, - SessionPipe, ], exports: [ Identity,