diff --git a/codegen.mts b/codegen.mts index 93196c66f..6adbbfaea 100644 --- a/codegen.mts +++ b/codegen.mts @@ -36,10 +36,9 @@ const config: CodegenConfig = { ProjectType: '../shared/entities#ProjectType', NativeFederationCompatibilityStatus: '../shared/entities#NativeFederationCompatibilityStatus', - TargetAccessScope: '../modules/auth/providers/target-access#TargetAccessScope', - ProjectAccessScope: '../modules/auth/providers/project-access#ProjectAccessScope', - OrganizationAccessScope: - '../modules/auth/providers/organization-access#OrganizationAccessScope', + TargetAccessScope: '../modules/auth/providers/scopes#TargetAccessScope', + ProjectAccessScope: '../modules/auth/providers/scopes#ProjectAccessScope', + OrganizationAccessScope: '../modules/auth/providers/scopes#OrganizationAccessScope', SupportTicketPriority: '../shared/entities#SupportTicketPriority', SupportTicketStatus: '../shared/entities#SupportTicketStatus', }, diff --git a/packages/services/api/src/modules/auth/index.ts b/packages/services/api/src/modules/auth/index.ts index bbc54c2dc..8470b087e 100644 --- a/packages/services/api/src/modules/auth/index.ts +++ b/packages/services/api/src/modules/auth/index.ts @@ -1,10 +1,7 @@ import { createModule } from 'graphql-modules'; import { AuditLogManager } from '../audit-logs/providers/audit-logs-manager'; import { AuthManager } from './providers/auth-manager'; -import { OrganizationAccess } from './providers/organization-access'; import { OrganizationAccessTokenValidationCache } from './providers/organization-access-token-validation-cache'; -import { ProjectAccess } from './providers/project-access'; -import { TargetAccess } from './providers/target-access'; import { UserManager } from './providers/user-manager'; import { resolvers } from './resolvers.generated'; import typeDefs from './module.graphql'; @@ -14,13 +11,5 @@ export const authModule = createModule({ dirname: __dirname, typeDefs, resolvers, - providers: [ - AuthManager, - UserManager, - OrganizationAccess, - ProjectAccess, - TargetAccess, - AuditLogManager, - OrganizationAccessTokenValidationCache, - ], + providers: [AuthManager, UserManager, AuditLogManager, OrganizationAccessTokenValidationCache], }); diff --git a/packages/services/api/src/modules/auth/lib/authz.spec.ts b/packages/services/api/src/modules/auth/lib/authz.spec.ts index 993dfb8a2..ecaa14d88 100644 --- a/packages/services/api/src/modules/auth/lib/authz.spec.ts +++ b/packages/services/api/src/modules/auth/lib/authz.spec.ts @@ -15,6 +15,10 @@ class TestSession extends Session { ): Promise> | Array { return this.policyStatements; } + + getActor(): Promise { + throw new Error('Not implemented'); + } } describe('Session.assertPerformAction', () => { diff --git a/packages/services/api/src/modules/auth/lib/authz.ts b/packages/services/api/src/modules/auth/lib/authz.ts index 5b24cca75..0e6163269 100644 --- a/packages/services/api/src/modules/auth/lib/authz.ts +++ b/packages/services/api/src/modules/auth/lib/authz.ts @@ -5,6 +5,7 @@ import type { User } from '../../../shared/entities'; import { AccessError } from '../../../shared/errors'; import { objectEntries, objectFromEntries } from '../../../shared/helpers'; import { isUUID } from '../../../shared/is-uuid'; +import type { OrganizationAccessToken } from '../../organization/providers/organization-access-tokens'; import { Logger } from '../../shared/providers/logger'; export type AuthorizationPolicyStatement = { @@ -47,6 +48,18 @@ function parseResourceIdentifier(resource: string) { return { organizationId, resourceId: parts[2] }; } +export type UserActor = { + type: 'user'; + user: User; +}; + +export type OrganizationAccessTokenActor = { + type: 'organizationAccessToken'; + organizationAccessToken: OrganizationAccessToken; +}; + +type Actor = UserActor | OrganizationAccessTokenActor; + /** * Abstract session class that is implemented by various ways to identify a session. * A session is a way to identify a user and their permissions for a specific organization. @@ -75,8 +88,21 @@ export abstract class Session { abstract readonly id: string; /** Retrieve the current viewer. Implementations of the session need to implement this function */ - public getViewer(): Promise { - throw new AccessError('Authorization token is missing', 'UNAUTHENTICATED'); + public abstract getActor(): Promise; + + /** + * Retrieve the Viewer of the session. + * A viewer can only be a {User} aka {SuperTokensSessions{}. + * If the session does not have a user an exception is raised. + */ + public async getViewer(): Promise { + const actor = await this.getActor(); + + if (actor.type !== 'user') { + throw new AccessError('Only authenticated users can perform this action.'); + } + + return actor.user; } public isViewer(): boolean { @@ -498,6 +524,10 @@ class UnauthenticatedSession extends Session { return []; } id = 'noop'; + + public getActor(): Promise { + throw new AccessError('Authorization token is missing', 'UNAUTHENTICATED'); + } } /** diff --git a/packages/services/api/src/modules/auth/lib/organization-access-token-strategy.ts b/packages/services/api/src/modules/auth/lib/organization-access-token-strategy.ts index 97aa038a9..e8ce1dd10 100644 --- a/packages/services/api/src/modules/auth/lib/organization-access-token-strategy.ts +++ b/packages/services/api/src/modules/auth/lib/organization-access-token-strategy.ts @@ -1,10 +1,16 @@ import * as crypto from 'node:crypto'; import { type FastifyReply, type FastifyRequest } from '@hive/service-common'; import * as OrganizationAccessKey from '../../organization/lib/organization-access-key'; +import type { OrganizationAccessToken } from '../../organization/providers/organization-access-tokens'; import { OrganizationAccessTokensCache } from '../../organization/providers/organization-access-tokens-cache'; import { Logger } from '../../shared/providers/logger'; import { OrganizationAccessTokenValidationCache } from '../providers/organization-access-token-validation-cache'; -import { AuthNStrategy, AuthorizationPolicyStatement, Session } from './authz'; +import { + AuthNStrategy, + AuthorizationPolicyStatement, + OrganizationAccessTokenActor, + Session, +} from './authz'; function hashToken(token: string) { return crypto.createHash('sha256').update(token).digest('hex'); @@ -13,6 +19,7 @@ function hashToken(token: string) { export class OrganizationAccessTokenSession extends Session { public readonly organizationId: string; private policies: Array; + private organizationAccessToken: OrganizationAccessToken; readonly id: string; constructor( @@ -20,6 +27,7 @@ export class OrganizationAccessTokenSession extends Session { id: string; organizationId: string; policies: Array; + organizationAccessToken: OrganizationAccessToken; }, deps: { logger: Logger; @@ -29,6 +37,14 @@ export class OrganizationAccessTokenSession extends Session { this.id = args.id; this.organizationId = args.organizationId; this.policies = args.policies; + this.organizationAccessToken = args.organizationAccessToken; + } + + public async getActor(): Promise { + return { + type: 'organizationAccessToken', + organizationAccessToken: this.organizationAccessToken, + }; } protected loadPolicyStatementsForOrganization( @@ -112,6 +128,7 @@ export class OrganizationAccessTokenStrategy extends AuthNStrategy> { - const user = await this.getViewer(); + const { user } = await this.getActor(); this.logger.debug( 'Loading policy statements for organization. (userId=%s, organizationId=%s)', @@ -97,7 +96,7 @@ export class SuperTokensCookieBasedSession extends Session { return organizationMembership.assignedRole.authorizationPolicyStatements; } - public async getViewer(): Promise { + public async getActor(): Promise { const user = await this.storage.getUserBySuperTokenId({ superTokensUserId: this.superTokensUserId, }); @@ -106,7 +105,10 @@ export class SuperTokensCookieBasedSession extends Session { throw new AccessError('User not found'); } - return user; + return { + type: 'user', + user, + }; } public isViewer() { diff --git a/packages/services/api/src/modules/auth/lib/target-access-token-strategy.ts b/packages/services/api/src/modules/auth/lib/target-access-token-strategy.ts index 224e57a04..22a08a01a 100644 --- a/packages/services/api/src/modules/auth/lib/target-access-token-strategy.ts +++ b/packages/services/api/src/modules/auth/lib/target-access-token-strategy.ts @@ -1,4 +1,5 @@ import { maskToken, type FastifyReply, type FastifyRequest } from '@hive/service-common'; +import { AccessError } from '../../../shared/errors'; import { Logger } from '../../shared/providers/logger'; import { TokenStorage } from '../../token/providers/token-storage'; import { TokensConfig } from '../../token/providers/tokens'; @@ -55,6 +56,10 @@ export class TargetAccessTokenSession extends Session { targetId: this.targetId, }; } + + public async getActor(): Promise { + throw new AccessError('Authorization token is missing', 'UNAUTHENTICATED'); + } } export class TargetAccessTokenStrategy extends AuthNStrategy { diff --git a/packages/services/api/src/modules/auth/module.graphql.mappers.ts b/packages/services/api/src/modules/auth/module.graphql.mappers.ts index 1d5042182..036cefdae 100644 --- a/packages/services/api/src/modules/auth/module.graphql.mappers.ts +++ b/packages/services/api/src/modules/auth/module.graphql.mappers.ts @@ -1,8 +1,10 @@ import type { User } from '../../shared/entities'; import { PermissionGroup, PermissionRecord } from '../organization/lib/permissions'; -import type { OrganizationAccessScope } from './providers/organization-access'; -import type { ProjectAccessScope } from './providers/project-access'; -import type { TargetAccessScope } from './providers/target-access'; +import type { + OrganizationAccessScope, + ProjectAccessScope, + TargetAccessScope, +} from './providers/scopes'; export type OrganizationAccessScopeMapper = OrganizationAccessScope; export type ProjectAccessScopeMapper = ProjectAccessScope; diff --git a/packages/services/api/src/modules/auth/providers/auth-manager.ts b/packages/services/api/src/modules/auth/providers/auth-manager.ts index 6bc15c797..2b55b15f7 100644 --- a/packages/services/api/src/modules/auth/providers/auth-manager.ts +++ b/packages/services/api/src/modules/auth/providers/auth-manager.ts @@ -1,15 +1,10 @@ +import DataLoader from 'dataloader'; import { Injectable, Scope } from 'graphql-modules'; import type { User } from '../../../shared/entities'; import { AccessError } from '../../../shared/errors'; +import { Storage } from '../../shared/providers/storage'; import { Session } from '../lib/authz'; -import { TargetAccessTokenSession } from '../lib/target-access-token-strategy'; -import { - OrganizationAccess, - OrganizationAccessScope, - OrganizationUserScopesSelector, -} from './organization-access'; -import { ProjectAccess, ProjectAccessScope, ProjectUserScopesSelector } from './project-access'; -import { TargetAccess, TargetAccessScope, TargetUserScopesSelector } from './target-access'; +import { OrganizationAccessScope, ProjectAccessScope, TargetAccessScope } from './scopes'; import { UserManager } from './user-manager'; export interface OrganizationAccessSelector { @@ -39,53 +34,48 @@ export interface TargetAccessSelector { global: true, }) export class AuthManager { + ownership: DataLoader< + { + organizationId: string; + }, + string | null, + string + >; + constructor( - private organizationAccess: OrganizationAccess, - private projectAccess: ProjectAccess, - private targetAccess: TargetAccess, private userManager: UserManager, private session: Session, - ) {} + private storage: Storage, + ) { + this.ownership = new DataLoader( + async selectors => { + const ownerPerSelector = await Promise.all( + selectors.map(selector => this.storage.getOrganizationOwnerId(selector)), + ); - async ensureOrganizationAccess(selector: OrganizationAccessSelector): Promise { - if (this.session instanceof TargetAccessTokenSession) { - await this.organizationAccess.ensureAccessForToken({ - ...selector, - token: this.session.token, - }); - } else { - const user = await this.session.getViewer(); - - // If a user is an admin, we can allow access for all data - if (user.isAdmin) { - return; - } - - await this.organizationAccess.ensureAccessForUser({ - ...selector, - userId: user.id, - }); - } - } - - async checkOrganizationAccess(selector: OrganizationAccessSelector): Promise { - if (this.session instanceof TargetAccessTokenSession) { - throw new Error('checkOrganizationAccess for token is not implemented yet'); - } - - const user = await this.session.getViewer(); - - return this.organizationAccess.checkAccessForUser({ - ...selector, - userId: user.id, - }); + return selectors.map((_, i) => ownerPerSelector[i]); + }, + { + cacheKeyFn(selector) { + return JSON.stringify({ + type: 'OrganizationAccess:ownership', + organization: selector.organizationId, + }); + }, + }, + ); } async ensureOrganizationOwnership(selector: { organization: string }): Promise { - const user = await this.session.getViewer(); - const isOwner = await this.organizationAccess.checkOwnershipForUser({ + const actor = await this.session.getActor(); + + if (actor.type !== 'user') { + throw new AccessError('Action can only be performed by user.'); + } + + const isOwner = await this.checkOwnershipForUser({ organizationId: selector.organization, - userId: user.id, + userId: actor.user.id, }); if (!isOwner) { @@ -93,54 +83,30 @@ export class AuthManager { } } - async getCurrentUserAccessScopes(organizationId: string) { - const user = await this.session.getViewer(); + private async checkOwnershipForUser(selector: OrganizationOwnershipSelector) { + const owner = await this.ownership.load(selector); - if (!user) { - throw new AccessError('User not found'); + if (!owner) { + return false; } - const [organizationScopes, projectScopes, targetScopes] = await Promise.all([ - this.getMemberOrganizationScopes({ - organizationId: organizationId, - userId: user.id, - }), - this.getMemberProjectScopes({ - organizationId: organizationId, - userId: user.id, - }), - this.getMemberTargetScopes({ - organizationId: organizationId, - userId: user.id, - }), - ]); - - return [...organizationScopes, ...projectScopes, ...targetScopes]; + return owner === selector.userId; } async updateCurrentUser(input: { displayName: string; fullName: string }): Promise { - const user = await this.session.getViewer(); + const actor = await this.session.getActor(); + if (actor.type !== 'user') { + throw new AccessError('Action can only be performed by user.'); + } + return this.userManager.updateUser({ - id: user.id, + id: actor.user.id, ...input, }); } - - getMemberOrganizationScopes(selector: OrganizationUserScopesSelector) { - return this.organizationAccess.getMemberScopes(selector); - } - - getMemberProjectScopes(selector: ProjectUserScopesSelector) { - return this.projectAccess.getMemberScopes(selector); - } - - getMemberTargetScopes(selector: TargetUserScopesSelector) { - return this.targetAccess.getMemberScopes(selector); - } - - resetAccessCache() { - this.organizationAccess.resetAccessCache(); - this.projectAccess.resetAccessCache(); - this.targetAccess.resetAccessCache(); - } +} + +export interface OrganizationOwnershipSelector { + userId: string; + organizationId: string; } diff --git a/packages/services/api/src/modules/auth/providers/organization-access.ts b/packages/services/api/src/modules/auth/providers/organization-access.ts deleted file mode 100644 index 2902ed422..000000000 --- a/packages/services/api/src/modules/auth/providers/organization-access.ts +++ /dev/null @@ -1,249 +0,0 @@ -import DataLoader from 'dataloader'; -import { forwardRef, Inject, Injectable, Scope } from 'graphql-modules'; -import { Token } from '../../../shared/entities'; -import { AccessError } from '../../../shared/errors'; -import { Logger } from '../../shared/providers/logger'; -import { Storage } from '../../shared/providers/storage'; -import { TokenSelector, TokenStorage } from '../../token/providers/token-storage'; -import type { ProjectAccessScope } from './project-access'; -import { OrganizationAccessScope } from './scopes'; -import type { TargetAccessScope } from './target-access'; - -export { OrganizationAccessScope } from './scopes'; - -export interface OrganizationOwnershipSelector { - userId: string; - organizationId: string; -} - -export interface OrganizationUserScopesSelector { - userId: string; - organizationId: string; -} - -export interface OrganizationUserAccessSelector { - userId: string; - organizationId: string; - scope: OrganizationAccessScope; -} - -interface OrganizationTokenAccessSelector { - token: string; - organizationId: string; - scope: OrganizationAccessScope; -} - -const organizationAccessScopeValues = Object.values(OrganizationAccessScope); - -export function isOrganizationScope(scope: any): scope is OrganizationAccessScope { - return organizationAccessScopeValues.includes(scope); -} - -@Injectable({ - scope: Scope.Operation, -}) -export class OrganizationAccess { - private logger: Logger; - private userAccess: DataLoader; - private tokenAccess: DataLoader; - private allScopes: DataLoader< - OrganizationUserScopesSelector, - ReadonlyArray, - string - >; - private scopes: DataLoader< - OrganizationUserScopesSelector, - readonly OrganizationAccessScope[], - string - >; - tokenInfo: DataLoader; - ownership: DataLoader< - { - organizationId: string; - }, - string | null, - string - >; - - constructor( - logger: Logger, - private storage: Storage, - @Inject(forwardRef(() => TokenStorage)) private tokenStorage: TokenStorage, - ) { - this.logger = logger.child({ - source: 'OrganizationAccess', - }); - this.userAccess = new DataLoader( - async selectors => { - const scopes = await this.scopes.loadMany(selectors); - - return selectors.map((selector, i) => { - const scopesForSelector = scopes[i]; - - if (scopesForSelector instanceof Error) { - this.logger.warn( - `OrganizationAccess:user (error=%s, selector=%o)`, - scopesForSelector.message, - selector, - ); - return false; - } - - return scopesForSelector.includes(selector.scope); - }); - }, - { - cacheKeyFn(selector) { - return JSON.stringify({ - type: 'OrganizationAccess:user', - organization: selector.organizationId, - user: selector.userId, - scope: selector.scope, - }); - }, - }, - ); - this.tokenAccess = new DataLoader( - selectors => - Promise.all( - selectors.map(async selector => { - const tokenInfo = await this.tokenInfo.load(selector); - - if (tokenInfo?.organization === selector.organizationId) { - return tokenInfo.scopes.includes(selector.scope); - } - - return false; - }), - ), - { - cacheKeyFn(selector) { - return JSON.stringify({ - type: 'OrganizationAccess:token', - organization: selector.organizationId, - token: selector.token, - scope: selector.scope, - }); - }, - }, - ); - this.allScopes = new DataLoader( - async selectors => { - const scopesPerSelector = await this.storage.getOrganizationMemberAccessPairs(selectors); - - return selectors.map((_, i) => scopesPerSelector[i]); - }, - { - cacheKeyFn(selector) { - return JSON.stringify({ - type: 'OrganizationAccess:allScopes', - organization: selector.organizationId, - user: selector.userId, - }); - }, - }, - ); - this.scopes = new DataLoader( - async selectors => { - const scopesPerSelector = await this.allScopes.loadMany(selectors); - - return selectors.map((selector, i) => { - const scopes = scopesPerSelector[i]; - - if (scopes instanceof Error) { - this.logger.warn( - `OrganizationAccess:scopes (error=%s, selector=%o)`, - scopes.message, - selector, - ); - return []; - } - - return scopes.filter(isOrganizationScope); - }); - }, - { - cacheKeyFn(selector) { - return JSON.stringify({ - type: 'OrganizationAccess:scopes', - organization: selector.organizationId, - user: selector.userId, - }); - }, - }, - ); - - this.ownership = new DataLoader( - async selectors => { - const ownerPerSelector = await Promise.all( - selectors.map(selector => this.storage.getOrganizationOwnerId(selector)), - ); - - return selectors.map((_, i) => ownerPerSelector[i]); - }, - { - cacheKeyFn(selector) { - return JSON.stringify({ - type: 'OrganizationAccess:ownership', - organization: selector.organizationId, - }); - }, - }, - ); - - this.tokenInfo = new DataLoader( - selectors => Promise.all(selectors.map(selector => this.tokenStorage.getToken(selector))), - { - cacheKeyFn(selector) { - return selector.token; - }, - }, - ); - } - - async ensureAccessForToken(selector: OrganizationTokenAccessSelector): Promise { - const canAccess = await this.tokenAccess.load(selector); - - if (!canAccess) { - throw new AccessError(`Missing ${selector.scope} permission`); - } - } - - async ensureAccessForUser(selector: OrganizationUserAccessSelector): Promise { - const canAccess = await this.userAccess.load(selector); - - if (!canAccess) { - throw new AccessError(`Missing ${selector.scope} permission`); - } - } - - async checkAccessForUser(selector: OrganizationUserAccessSelector): Promise { - return this.userAccess.load(selector); - } - - async checkOwnershipForUser(selector: OrganizationOwnershipSelector) { - const owner = await this.ownership.load(selector); - - if (!owner) { - return false; - } - - return owner === selector.userId; - } - - async getMemberScopes(selector: OrganizationUserScopesSelector) { - return this.scopes.load(selector); - } - - async getAllScopes(selectors: readonly OrganizationUserScopesSelector[]) { - return this.allScopes.loadMany(selectors); - } - - resetAccessCache() { - this.userAccess.clearAll(); - this.tokenAccess.clearAll(); - this.allScopes.clearAll(); - this.scopes.clearAll(); - this.tokenInfo.clearAll(); - } -} diff --git a/packages/services/api/src/modules/auth/providers/project-access.ts b/packages/services/api/src/modules/auth/providers/project-access.ts deleted file mode 100644 index 20dcf62ae..000000000 --- a/packages/services/api/src/modules/auth/providers/project-access.ts +++ /dev/null @@ -1,166 +0,0 @@ -import Dataloader from 'dataloader'; -import { Injectable, Scope } from 'graphql-modules'; -import { AccessError } from '../../../shared/errors'; -import { Logger } from '../../shared/providers/logger'; -import { OrganizationAccess } from './organization-access'; -import { ProjectAccessScope } from './scopes'; - -export { ProjectAccessScope } from './scopes'; - -export interface ProjectUserAccessSelector { - userId: string; - organizationId: string; - projectId: string; - scope: ProjectAccessScope; -} - -export interface ProjectUserScopesSelector { - userId: string; - organizationId: string; -} - -interface ProjectTokenAccessSelector { - token: string; - organizationId: string; - projectId: string; - scope: ProjectAccessScope; -} - -const projectAccessScopeValues = Object.values(ProjectAccessScope); - -export function isProjectScope(scope: any): scope is ProjectAccessScope { - return projectAccessScopeValues.includes(scope); -} - -@Injectable({ - scope: Scope.Operation, -}) -export class ProjectAccess { - private logger: Logger; - private userAccess: Dataloader; - private tokenAccess: Dataloader; - private scopes: Dataloader; - - constructor( - logger: Logger, - private organizationAccess: OrganizationAccess, - ) { - this.logger = logger.child({ - source: 'ProjectAccess', - }); - this.userAccess = new Dataloader( - async selectors => { - const scopes = await this.scopes.loadMany(selectors); - - return selectors.map((selector, i) => { - const scopesForSelector = scopes[i]; - - if (scopesForSelector instanceof Error) { - this.logger.warn( - `ProjectAccess:user (error=%s, selector=%o)`, - scopesForSelector.message, - selector, - ); - return false; - } - - return scopesForSelector.includes(selector.scope); - }); - }, - { - cacheKeyFn(selector) { - return JSON.stringify({ - type: 'ProjectAccess:user', - organization: selector.organizationId, - project: selector.projectId, - user: selector.userId, - scope: selector.scope, - }); - }, - }, - ); - this.tokenAccess = new Dataloader( - selectors => - Promise.all( - selectors.map(async selector => { - const tokenInfo = await this.organizationAccess.tokenInfo.load(selector); - - if ( - tokenInfo?.organization === selector.organizationId && - tokenInfo?.project === selector.projectId - ) { - return tokenInfo.scopes.includes(selector.scope); - } - - return false; - }), - ), - { - cacheKeyFn(selector) { - return JSON.stringify({ - type: 'ProjectAccess:token', - organization: selector.organizationId, - project: selector.projectId, - token: selector.token, - scope: selector.scope, - }); - }, - }, - ); - this.scopes = new Dataloader( - async selectors => { - const scopesPerSelector = await this.organizationAccess.getAllScopes(selectors); - - return selectors.map((selector, i) => { - const scopes = scopesPerSelector[i]; - - if (scopes instanceof Error) { - this.logger.debug( - `ProjectAccess:scopes (error=%s, selector=%o)`, - scopes.message, - selector, - ); - return []; - } - - return scopes.filter(isProjectScope); - }); - }, - { - cacheKeyFn(selector) { - return JSON.stringify({ - type: 'ProjectAccess:scopes', - organization: selector.organizationId, - user: selector.userId, - }); - }, - }, - ); - } - - async ensureAccessForToken(selector: ProjectTokenAccessSelector): Promise { - const canAccess = await this.tokenAccess.load(selector); - - if (!canAccess) { - throw new AccessError(`Missing ${selector.scope} permission`); - } - } - - async ensureAccessForUser(selector: ProjectUserAccessSelector): Promise { - const canAccess = await this.userAccess.load(selector); - - if (!canAccess) { - throw new AccessError(`Missing ${selector.scope} permission`); - } - } - - async getMemberScopes(selector: ProjectUserScopesSelector) { - return this.scopes.load(selector); - } - - resetAccessCache() { - this.userAccess.clearAll(); - this.tokenAccess.clearAll(); - this.scopes.clearAll(); - } -} diff --git a/packages/services/api/src/modules/auth/providers/target-access.ts b/packages/services/api/src/modules/auth/providers/target-access.ts deleted file mode 100644 index 99f0d22b5..000000000 --- a/packages/services/api/src/modules/auth/providers/target-access.ts +++ /dev/null @@ -1,172 +0,0 @@ -import Dataloader from 'dataloader'; -import { Injectable, Scope } from 'graphql-modules'; -import { AccessError } from '../../../shared/errors'; -import { Logger } from '../../shared/providers/logger'; -import { OrganizationAccess } from './organization-access'; -import { TargetAccessScope } from './scopes'; - -export { TargetAccessScope } from './scopes'; - -export interface TargetUserAccessSelector { - userId: string; - organizationId: string; - projectId: string; - targetId: string; - scope: TargetAccessScope; -} - -export interface TargetUserScopesSelector { - userId: string; - organizationId: string; -} - -interface TargetTokenAccessSelector { - token: string; - organizationId: string; - projectId: string; - targetId: string; - scope: TargetAccessScope; -} - -const targetAccessScopeValues = Object.values(TargetAccessScope); - -export function isTargetScope(scope: any): scope is TargetAccessScope { - return targetAccessScopeValues.includes(scope); -} - -@Injectable({ - scope: Scope.Operation, -}) -export class TargetAccess { - private logger: Logger; - private userAccess: Dataloader; - private tokenAccess: Dataloader; - private scopes: Dataloader; - - constructor( - logger: Logger, - private organizationAccess: OrganizationAccess, - ) { - this.logger = logger.child({ - source: 'TargetAccess', - }); - this.userAccess = new Dataloader( - async selectors => { - const scopes = await this.scopes.loadMany(selectors); - - return selectors.map((selector, i) => { - const scopesForSelector = scopes[i]; - - if (scopesForSelector instanceof Error) { - this.logger.warn( - `TargetAccess:user (error=%s, selector=%o)`, - scopesForSelector.message, - selector, - ); - return false; - } - - return scopesForSelector.includes(selector.scope); - }); - }, - { - cacheKeyFn(selector) { - return JSON.stringify({ - type: 'TargetAccess:user', - organization: selector.organizationId, - project: selector.projectId, - target: selector.targetId, - user: selector.userId, - scope: selector.scope, - }); - }, - }, - ); - this.tokenAccess = new Dataloader( - selectors => - Promise.all( - selectors.map(async selector => { - const tokenInfo = await this.organizationAccess.tokenInfo.load(selector); - - if ( - tokenInfo?.organization === selector.organizationId && - tokenInfo?.project === selector.projectId && - tokenInfo?.target === selector.targetId - ) { - return tokenInfo.scopes.includes(selector.scope); - } - - return false; - }), - ), - { - cacheKeyFn(selector) { - return JSON.stringify({ - type: 'TargetAccess:token', - organization: selector.organizationId, - project: selector.projectId, - target: selector.targetId, - token: selector.token, - scope: selector.scope, - }); - }, - }, - ); - - this.scopes = new Dataloader( - async selectors => { - const scopesPerSelector = await this.organizationAccess.getAllScopes(selectors); - - return selectors.map((selector, i) => { - const scopes = scopesPerSelector[i]; - - if (scopes instanceof Error) { - this.logger.warn( - `TargetAccess:scopes (error=%s, selector=%o)`, - scopes.message, - selector, - ); - return []; - } - - return scopes.filter(isTargetScope); - }); - }, - { - cacheKeyFn(selector) { - return JSON.stringify({ - type: 'TargetAccess:scopes', - organization: selector.organizationId, - user: selector.userId, - }); - }, - }, - ); - } - - async ensureAccessForToken(selector: TargetTokenAccessSelector): Promise { - const canAccess = await this.tokenAccess.load(selector); - - if (!canAccess) { - throw new AccessError(`Missing ${selector.scope} permission`); - } - } - - async ensureAccessForUser(selector: TargetUserAccessSelector): Promise { - const canAccess = await this.userAccess.load(selector); - - if (!canAccess) { - throw new AccessError(`Missing ${selector.scope} permission`); - } - } - - async getMemberScopes(selector: TargetUserScopesSelector) { - return this.scopes.load(selector); - } - - resetAccessCache() { - this.userAccess.clearAll(); - this.tokenAccess.clearAll(); - this.scopes.clearAll(); - } -} diff --git a/packages/services/api/src/modules/auth/resolvers/OrganizationAccessScope.ts b/packages/services/api/src/modules/auth/resolvers/OrganizationAccessScope.ts index 8f9fc4245..1d25f5414 100644 --- a/packages/services/api/src/modules/auth/resolvers/OrganizationAccessScope.ts +++ b/packages/services/api/src/modules/auth/resolvers/OrganizationAccessScope.ts @@ -1,4 +1,4 @@ -import { OrganizationAccessScope as OrganizationAccessScopeEnum } from '../providers/organization-access'; +import { OrganizationAccessScope as OrganizationAccessScopeEnum } from '../providers/scopes'; import type { OrganizationAccessScopeResolvers } from './../../../__generated__/types'; export const OrganizationAccessScope: OrganizationAccessScopeResolvers = { diff --git a/packages/services/api/src/modules/auth/resolvers/ProjectAccessScope.ts b/packages/services/api/src/modules/auth/resolvers/ProjectAccessScope.ts index d4dc7e96a..9daadf6e0 100644 --- a/packages/services/api/src/modules/auth/resolvers/ProjectAccessScope.ts +++ b/packages/services/api/src/modules/auth/resolvers/ProjectAccessScope.ts @@ -1,4 +1,4 @@ -import { ProjectAccessScope as ProjectAccessScopeEnum } from '../providers/project-access'; +import { ProjectAccessScope as ProjectAccessScopeEnum } from '../providers/scopes'; import type { ProjectAccessScopeResolvers } from './../../../__generated__/types'; export const ProjectAccessScope: ProjectAccessScopeResolvers = { diff --git a/packages/services/api/src/modules/auth/resolvers/TargetAccessScope.ts b/packages/services/api/src/modules/auth/resolvers/TargetAccessScope.ts index f3d9ead24..a7e54a215 100644 --- a/packages/services/api/src/modules/auth/resolvers/TargetAccessScope.ts +++ b/packages/services/api/src/modules/auth/resolvers/TargetAccessScope.ts @@ -1,4 +1,4 @@ -import { TargetAccessScope as TargetAccessScopeEnum } from '../providers/target-access'; +import { TargetAccessScope as TargetAccessScopeEnum } from '../providers/scopes'; import type { TargetAccessScopeResolvers } from './../../../__generated__/types'; export const TargetAccessScope: TargetAccessScopeResolvers = { diff --git a/packages/services/api/src/modules/organization/providers/organization-manager.ts b/packages/services/api/src/modules/organization/providers/organization-manager.ts index 739ca9f3b..ace7feb3a 100644 --- a/packages/services/api/src/modules/organization/providers/organization-manager.ts +++ b/packages/services/api/src/modules/organization/providers/organization-manager.ts @@ -188,7 +188,6 @@ export class OrganizationManager { }); // Because we checked the access before, it's stale by now - this.authManager.resetAccessCache(); this.session.reset(); return { @@ -329,7 +328,6 @@ export class OrganizationManager { await this.tokenStorage.invalidateTokens(deletedOrganization.tokens); // Because we checked the access before, it's stale by now - this.authManager.resetAccessCache(); this.session.reset(); await this.auditLog.record({ @@ -609,7 +607,6 @@ export class OrganizationManager { }); // Because we checked the access before, it's stale by now - this.authManager.resetAccessCache(); this.session.reset(); await Promise.all([ @@ -791,7 +788,6 @@ export class OrganizationManager { }); // Because we checked the access before, it's stale by now - this.authManager.resetAccessCache(); this.session.reset(); await this.auditLog.record({ @@ -994,7 +990,6 @@ export class OrganizationManager { }); // Access cache is stale by now - this.authManager.resetAccessCache(); this.session.reset(); const previousMemberRole = previousMembership.assignedRole.role ?? null; @@ -1109,7 +1104,6 @@ export class OrganizationManager { }); // Access cache is stale by now - this.authManager.resetAccessCache(); this.session.reset(); await this.auditLog.record({ diff --git a/packages/services/api/src/modules/shared/providers/storage.ts b/packages/services/api/src/modules/shared/providers/storage.ts index 6e9b6a53a..e430e3994 100644 --- a/packages/services/api/src/modules/shared/providers/storage.ts +++ b/packages/services/api/src/modules/shared/providers/storage.ts @@ -34,9 +34,11 @@ import type { TargetSettings, User, } from '../../../shared/entities'; -import type { OrganizationAccessScope } from '../../auth/providers/organization-access'; -import type { ProjectAccessScope } from '../../auth/providers/project-access'; -import type { TargetAccessScope } from '../../auth/providers/target-access'; +import type { + OrganizationAccessScope, + ProjectAccessScope, + TargetAccessScope, +} from '../../auth/providers/scopes'; import type { Contracts } from '../../schema/providers/contracts'; import type { SchemaCoordinatesDiffResult } from '../../schema/providers/inspector'; diff --git a/packages/services/api/src/modules/token/providers/token-manager.ts b/packages/services/api/src/modules/token/providers/token-manager.ts index 9fd8619f2..ed0a723a0 100644 --- a/packages/services/api/src/modules/token/providers/token-manager.ts +++ b/packages/services/api/src/modules/token/providers/token-manager.ts @@ -5,9 +5,11 @@ import { HiveError } from '../../../shared/errors'; import { pushIfMissing } from '../../../shared/helpers'; import { AuditLogRecorder } from '../../audit-logs/providers/audit-log-recorder'; import { Session } from '../../auth/lib/authz'; -import { OrganizationAccessScope } from '../../auth/providers/organization-access'; -import { ProjectAccessScope } from '../../auth/providers/project-access'; -import { TargetAccessScope } from '../../auth/providers/target-access'; +import { + OrganizationAccessScope, + ProjectAccessScope, + TargetAccessScope, +} from '../../auth/providers/scopes'; import { OrganizationMembers } from '../../organization/providers/organization-members'; import { Logger } from '../../shared/providers/logger'; import { Storage, TargetSelector } from '../../shared/providers/storage'; diff --git a/packages/services/api/src/modules/token/providers/token-storage.ts b/packages/services/api/src/modules/token/providers/token-storage.ts index 3cfadd9ca..1aefcd31e 100644 --- a/packages/services/api/src/modules/token/providers/token-storage.ts +++ b/packages/services/api/src/modules/token/providers/token-storage.ts @@ -5,9 +5,11 @@ import { createTRPCProxyClient, httpLink } from '@trpc/client'; import type { Token } from '../../../shared/entities'; import { HiveError } from '../../../shared/errors'; import { atomic } from '../../../shared/helpers'; -import type { OrganizationAccessScope } from '../../auth/providers/organization-access'; -import type { ProjectAccessScope } from '../../auth/providers/project-access'; -import type { TargetAccessScope } from '../../auth/providers/target-access'; +import type { + OrganizationAccessScope, + ProjectAccessScope, + TargetAccessScope, +} from '../../auth/providers/scopes'; import { Logger } from '../../shared/providers/logger'; import type { TargetSelector } from '../../shared/providers/storage'; import type { TokensConfig } from './tokens';