Skip to content

Commit 15812b1

Browse files
authored
Authentication Identity facade service (#3437)
2 parents 3a67003 + 64fb40a commit 15812b1

File tree

51 files changed

+327
-323
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+327
-323
lines changed

src/common/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export * from './secured-mapper';
4949
export * from './sensitivity.enum';
5050
export * from './trace-layer';
5151
export * from './util';
52-
export { type Session, LoggedInSession, AnonSession } from './session';
52+
export { type Session } from './session';
5353
export * from './types';
5454
export * from './validators';
5555
export * from './name-field';

src/common/session.ts

Lines changed: 0 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,5 @@
1-
import {
2-
Injectable,
3-
Param,
4-
type PipeTransform,
5-
type Type,
6-
} from '@nestjs/common';
7-
import { CONTROLLER_WATERMARK } from '@nestjs/common/constants.js';
8-
import { Context } from '@nestjs/graphql';
9-
import { uniq } from 'lodash';
101
import { type DateTime } from 'luxon';
11-
import { SessionHost } from '../components/authentication/session.host';
122
import { type ScopedRole } from '../components/authorization/dto';
13-
import { UnauthenticatedException } from './exceptions';
143
import { type ID } from './id-field';
154

165
export interface Session {
@@ -32,49 +21,3 @@ export interface Session {
3221
roles: readonly ScopedRole[];
3322
};
3423
}
35-
36-
export function loggedInSession(session: Session): Session {
37-
if (session.anonymous) {
38-
throw new UnauthenticatedException('User is not logged in');
39-
}
40-
return session;
41-
}
42-
43-
@Injectable()
44-
export class SessionPipe implements PipeTransform {
45-
constructor(private readonly sessionHost: SessionHost) {}
46-
47-
transform() {
48-
return this.sessionHost.currentMaybe;
49-
}
50-
}
51-
52-
/** @deprecated */
53-
export const LoggedInSession = () =>
54-
AnonSession({ transform: loggedInSession });
55-
56-
/** @deprecated */
57-
export const AnonSession =
58-
(...pipes: Array<Type<PipeTransform> | PipeTransform>): ParameterDecorator =>
59-
(...args) => {
60-
Context(SessionPipe, ...pipes)(...args);
61-
process.nextTick(() => {
62-
// Only set this metadata if it's a controller method.
63-
// Waiting for the next tick as class decorators execute after methods.
64-
if (Reflect.getMetadata(CONTROLLER_WATERMARK, args[0].constructor)) {
65-
Param(SessionPipe, ...pipes)(...args);
66-
SessionWatermark(...args);
67-
}
68-
});
69-
};
70-
71-
const SessionWatermark: ParameterDecorator = (target, key) =>
72-
Reflect.defineMetadata('SESSION_WATERMARK', true, target.constructor, key!);
73-
74-
export const addScope = (session: Session, scope?: ScopedRole[]) => ({
75-
...session,
76-
roles: uniq([...session.roles, ...(scope ?? [])]),
77-
});
78-
79-
export const isAdmin = (session: Session) =>
80-
session.roles.includes('global:Administrator');

src/components/authentication/authentication.module.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { forwardRef, Global, Module } from '@nestjs/common';
22
import { APP_INTERCEPTOR } from '@nestjs/core';
3-
import { SessionPipe } from '~/common/session';
43
import { splitDb } from '~/core';
4+
import { Identity } from '~/core/authentication';
55
import { AuthorizationModule } from '../authorization/authorization.module';
66
import { UserModule } from '../user/user.module';
77
import { AuthenticationGelRepository } from './authentication.gel.repository';
@@ -34,16 +34,17 @@ import { SessionResolver } from './session.resolver';
3434
SessionExtraInfoResolver,
3535
LoginExtraInfoResolver,
3636
RegisterExtraInfoResolver,
37+
Identity,
3738
AuthenticationService,
3839
splitDb(AuthenticationRepository, AuthenticationGelRepository),
3940
{ provide: 'AUTHENTICATION', useExisting: AuthenticationService },
4041
CryptoService,
4142
SessionInterceptor,
4243
{ provide: APP_INTERCEPTOR, useExisting: SessionInterceptor },
4344
{ provide: SessionHost, useClass: SessionHostImpl },
44-
SessionPipe,
4545
],
4646
exports: [
47+
Identity,
4748
SessionHost,
4849
SessionInterceptor,
4950
AuthenticationService,

src/components/authentication/authentication.service.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,17 @@ export class AuthenticationService {
256256
return session;
257257
}
258258

259+
asRole<R>(role: Role, fn: () => R): R {
260+
const session: Session = {
261+
token: 'system',
262+
issuedAt: DateTime.now(),
263+
userId: 'anonymous' as ID,
264+
anonymous: false,
265+
roles: [`global:${role}`],
266+
};
267+
return this.sessionHost.withSession(session, fn);
268+
}
269+
259270
async changePassword(
260271
oldPassword: string,
261272
newPassword: string,

src/components/authentication/extra-info.resolver.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,6 @@
11
import { ResolveField, Resolver } from '@nestjs/graphql';
22
import { mapValues } from '@seedcompany/common';
3-
import {
4-
type AbstractClassType,
5-
AnonSession,
6-
EnhancedResource,
7-
type Session,
8-
} from '~/common';
3+
import { type AbstractClassType, EnhancedResource } from '~/common';
94
import { Privileges } from '../authorization';
105
import { BetaFeatures } from '../authorization/dto/beta-features.dto';
116
import { LoginOutput, RegisterOutput, SessionOutput } from './dto';
@@ -16,7 +11,7 @@ function AuthExtraInfoResolver(concreteClass: AbstractClassType<any>) {
1611
constructor(private readonly privileges: Privileges) {}
1712

1813
@ResolveField(() => BetaFeatures)
19-
betaFeatures(@AnonSession() session: Session): BetaFeatures {
14+
betaFeatures(): BetaFeatures {
2015
const privileges = this.privileges.for(BetaFeatures);
2116
const { props } = EnhancedResource.of(BetaFeatures);
2217
return mapValues.fromList([...props], (prop) =>

src/components/authentication/login.resolver.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
Resolver,
77
} from '@nestjs/graphql';
88
import { stripIndent } from 'common-tags';
9-
import { AnonSession, type Session } from '~/common';
109
import { Loader, type LoaderOf } from '~/core';
1110
import { Privileges } from '../authorization';
1211
import { Power } from '../authorization/dto';
@@ -15,11 +14,13 @@ import { User } from '../user/dto';
1514
import { Anonymous } from './anonymous.decorator';
1615
import { AuthenticationService } from './authentication.service';
1716
import { LoginInput, LoginOutput, LogoutOutput } from './dto';
17+
import { SessionHost } from './session.host';
1818

1919
@Resolver(LoginOutput)
2020
export class LoginResolver {
2121
constructor(
2222
private readonly authentication: AuthenticationService,
23+
private readonly sessionHost: SessionHost,
2324
private readonly privileges: Privileges,
2425
) {}
2526

@@ -43,7 +44,8 @@ export class LoginResolver {
4344
`,
4445
})
4546
@Anonymous()
46-
async logout(@AnonSession() session: Session): Promise<LogoutOutput> {
47+
async logout(): Promise<LogoutOutput> {
48+
const session = this.sessionHost.current;
4749
await this.authentication.logout(session.token);
4850
await this.authentication.refreshCurrentSession(); // ensure session data is fresh
4951
return { success: true };

src/components/authentication/session.interceptor.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ import {
2121
type Session,
2222
UnauthenticatedException,
2323
} from '~/common';
24-
import { loggedInSession as verifyLoggedIn } from '~/common/session';
2524
import { ConfigService } from '~/core';
25+
import { Identity } from '~/core/authentication';
2626
import { GlobalHttpHook, type IRequest } from '~/core/http';
2727
import { rolesForScope } from '../authorization/dto';
2828
import { Anonymous } from './anonymous.decorator';
@@ -36,6 +36,7 @@ export class SessionInterceptor implements NestInterceptor {
3636
private readonly auth: AuthenticationService & {},
3737
private readonly config: ConfigService,
3838
private readonly sessionHost: SessionHost,
39+
private readonly identity: Identity,
3940
) {}
4041

4142
private readonly sessionByRequest = new AsyncLocalStorage<
@@ -85,7 +86,7 @@ export class SessionInterceptor implements NestInterceptor {
8586
Anonymous.get(executionContext.getClass()) ??
8687
!isMutation;
8788
if (!allowAnonymous && session) {
88-
verifyLoggedIn(session);
89+
this.identity.verifyLoggedIn();
8990
}
9091

9192
return next.handle();

src/components/authorization/policy/executor/edge-privileges.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export class EdgePrivileges<
5757
: perm.isAllowed({
5858
object: this.object,
5959
resource: this.resource,
60-
session: this.policyExecutor.sessionHost.current,
60+
session: this.policyExecutor.identity.current,
6161
});
6262
}
6363

src/components/authorization/policy/executor/policy-dumper.ts

Lines changed: 44 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,15 @@ import { Chalk, type ChalkInstance } from 'chalk';
1515
import Table from 'cli-table3';
1616
import { Command, Option } from 'clipanion';
1717
import { startCase } from 'lodash';
18-
import { DateTime } from 'luxon';
1918
import fs from 'node:fs/promises';
2019
import { type LiteralUnion } from 'type-fest';
2120
import { inspect } from 'util';
2221
import xlsx from 'xlsx';
23-
import {
24-
type EnhancedResource,
25-
firstOr,
26-
type ID,
27-
Role,
28-
type Session,
29-
} from '~/common';
22+
import { type EnhancedResource, firstOr, Role } from '~/common';
3023
import { searchCamelCase } from '~/common/search-camel-case';
31-
import { InjectableCommand, type ResourceLike, ResourcesHost } from '~/core';
24+
import { Identity } from '~/core/authentication';
25+
import { InjectableCommand } from '~/core/cli';
26+
import { type ResourceLike, ResourcesHost } from '~/core/resources';
3227
import {
3328
ChildListAction,
3429
ChildSingleAction,
@@ -44,6 +39,7 @@ type AnyResource = EnhancedResource<any>;
4439
@Injectable()
4540
export class PolicyDumper {
4641
constructor(
42+
private readonly identity: Identity,
4743
private readonly resources: ResourcesHost,
4844
private readonly executor: PolicyExecutor,
4945
) {}
@@ -172,51 +168,45 @@ export class PolicyDumper {
172168
resource: AnyResource,
173169
options: { props: boolean | ReadonlySet<string> },
174170
): DumpedRow[] {
175-
const session: Session = {
176-
token: 'system',
177-
issuedAt: DateTime.now(),
178-
userId: 'anonymous' as ID,
179-
anonymous: false,
180-
roles: [`global:${role}`],
181-
};
182-
const resolve = (action: string, prop?: string) =>
183-
this.executor.resolve({
184-
session,
185-
resource,
186-
calculatedAsCondition: true,
187-
optimizeConditions: true,
188-
action,
189-
prop,
190-
});
191-
return [
192-
{
193-
role,
194-
resource,
195-
edge: undefined,
196-
...mapValues.fromList(ResourceAction, (action) => resolve(action))
197-
.asRecord,
198-
},
199-
...(options.props !== false
200-
? ([
201-
[resource.securedPropsPlusExtra, PropAction],
202-
[resource.childSingleKeys, ChildSingleAction],
203-
[resource.childListKeys, ChildListAction],
204-
] as const)
205-
: []
206-
).flatMap(([set, actions]) =>
207-
[...set]
208-
.filter(
209-
(p) => typeof options.props === 'boolean' || options.props.has(p),
210-
)
211-
.map((prop) => ({
212-
role,
213-
resource,
214-
edge: prop,
215-
...mapValues.fromList(actions, (action) => resolve(action, prop))
216-
.asRecord,
217-
})),
218-
),
219-
];
171+
return this.identity.asRole(role, () => {
172+
const resolve = (action: string, prop?: string) =>
173+
this.executor.resolve({
174+
resource,
175+
calculatedAsCondition: true,
176+
optimizeConditions: true,
177+
action,
178+
prop,
179+
});
180+
return [
181+
{
182+
role,
183+
resource,
184+
edge: undefined,
185+
...mapValues.fromList(ResourceAction, (action) => resolve(action))
186+
.asRecord,
187+
},
188+
...(options.props !== false
189+
? ([
190+
[resource.securedPropsPlusExtra, PropAction],
191+
[resource.childSingleKeys, ChildSingleAction],
192+
[resource.childListKeys, ChildListAction],
193+
] as const)
194+
: []
195+
).flatMap(([set, actions]) =>
196+
[...set]
197+
.filter(
198+
(p) => typeof options.props === 'boolean' || options.props.has(p),
199+
)
200+
.map((prop) => ({
201+
role,
202+
resource,
203+
edge: prop,
204+
...mapValues.fromList(actions, (action) => resolve(action, prop))
205+
.asRecord,
206+
})),
207+
),
208+
];
209+
});
220210
}
221211
}
222212

src/components/authorization/policy/executor/policy-executor.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common';
22
import { CachedByArg } from '@seedcompany/common';
33
import { identity, intersection } from 'lodash';
44
import { type EnhancedResource, type Session } from '~/common';
5+
import { Identity } from '~/core/authentication';
56
import { type QueryFragment } from '~/core/database/query';
6-
import { SessionHost } from '../../../authentication/session.host';
77
import { withoutScope } from '../../dto';
88
import { RoleCondition } from '../../policies/conditions/role.condition';
99
import { type Permission } from '../builder/perm-granter';
@@ -40,7 +40,7 @@ export interface FilterOptions {
4040
@Injectable()
4141
export class PolicyExecutor {
4242
constructor(
43-
readonly sessionHost: SessionHost,
43+
readonly identity: Identity,
4444
private readonly policyFactory: PolicyFactory,
4545
@Inject(forwardRef(() => ConditionOptimizer))
4646
private readonly conditionOptimizer: ConditionOptimizer & {},
@@ -64,7 +64,7 @@ export class PolicyExecutor {
6464
}
6565
}
6666

67-
const session = this.sessionHost.current;
67+
const session = this.identity.current;
6868
const policies = this.getPolicies(session);
6969
const isChildRelation = prop && resource.childKeys.has(prop);
7070

@@ -187,7 +187,7 @@ export class PolicyExecutor {
187187

188188
const other = {
189189
resource: params.resource,
190-
session: this.sessionHost.current,
190+
session: this.identity.current,
191191
};
192192
return query
193193
.comment("Loading policy condition's context")

0 commit comments

Comments
 (0)