Skip to content

Authentication Identity facade service #3437

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
May 28, 2025
Merged
2 changes: 1 addition & 1 deletion src/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
57 changes: 0 additions & 57 deletions src/common/session.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,5 @@
import {
Injectable,
Param,
type PipeTransform,
type Type,
} 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 { SessionHost } from '../components/authentication/session.host';
import { type ScopedRole } from '../components/authorization/dto';
import { UnauthenticatedException } from './exceptions';
import { type ID } from './id-field';

export interface Session {
Expand All @@ -32,49 +21,3 @@ export interface Session {
roles: readonly ScopedRole[];
};
}

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 sessionHost: SessionHost) {}

transform() {
return this.sessionHost.currentMaybe;
}
}

/** @deprecated */
export const LoggedInSession = () =>
AnonSession({ transform: loggedInSession });

/** @deprecated */
export const AnonSession =
(...pipes: Array<Type<PipeTransform> | 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!);

export const addScope = (session: Session, scope?: ScopedRole[]) => ({
...session,
roles: uniq([...session.roles, ...(scope ?? [])]),
});

export const isAdmin = (session: Session) =>
session.roles.includes('global:Administrator');
5 changes: 3 additions & 2 deletions src/components/authentication/authentication.module.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,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';
Expand Down Expand Up @@ -34,16 +34,17 @@ import { SessionResolver } from './session.resolver';
SessionExtraInfoResolver,
LoginExtraInfoResolver,
RegisterExtraInfoResolver,
Identity,
AuthenticationService,
splitDb(AuthenticationRepository, AuthenticationGelRepository),
{ provide: 'AUTHENTICATION', useExisting: AuthenticationService },
CryptoService,
SessionInterceptor,
{ provide: APP_INTERCEPTOR, useExisting: SessionInterceptor },
{ provide: SessionHost, useClass: SessionHostImpl },
SessionPipe,
],
exports: [
Identity,
SessionHost,
SessionInterceptor,
AuthenticationService,
Expand Down
11 changes: 11 additions & 0 deletions src/components/authentication/authentication.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,17 @@ export class AuthenticationService {
return session;
}

asRole<R>(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,
Expand Down
9 changes: 2 additions & 7 deletions src/components/authentication/extra-info.resolver.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,7 +11,7 @@ function AuthExtraInfoResolver(concreteClass: AbstractClassType<any>) {
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) =>
Expand Down
6 changes: 4 additions & 2 deletions src/components/authentication/login.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
) {}

Expand All @@ -43,7 +44,8 @@ export class LoginResolver {
`,
})
@Anonymous()
async logout(@AnonSession() session: Session): Promise<LogoutOutput> {
async logout(): Promise<LogoutOutput> {
const session = this.sessionHost.current;
await this.authentication.logout(session.token);
await this.authentication.refreshCurrentSession(); // ensure session data is fresh
return { success: true };
Expand Down
5 changes: 3 additions & 2 deletions src/components/authentication/session.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}

Expand Down
98 changes: 44 additions & 54 deletions src/components/authorization/policy/executor/policy-dumper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,15 @@ 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 { InjectableCommand, type ResourceLike, ResourcesHost } from '~/core';
import { Identity } from '~/core/authentication';
import { InjectableCommand } from '~/core/cli';
import { type ResourceLike, ResourcesHost } from '~/core/resources';
import {
ChildListAction,
ChildSingleAction,
Expand All @@ -44,6 +39,7 @@ type AnyResource = EnhancedResource<any>;
@Injectable()
export class PolicyDumper {
constructor(
private readonly identity: Identity,
private readonly resources: ResourcesHost,
private readonly executor: PolicyExecutor,
) {}
Expand Down Expand Up @@ -172,51 +168,45 @@ export class PolicyDumper {
resource: AnyResource,
options: { props: boolean | ReadonlySet<string> },
): 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,
})),
),
];
});
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 & {},
Expand All @@ -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);

Expand Down Expand Up @@ -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")
Expand Down
Loading