chore: cleanup authorization code (#6736)

This commit is contained in:
Laurin Quast 2025-04-16 13:19:03 +02:00 committed by GitHub
parent d1b8d4ce1d
commit c6badf5fe7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 146 additions and 719 deletions

View file

@ -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',
},

View file

@ -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],
});

View file

@ -15,6 +15,10 @@ class TestSession extends Session {
): Promise<Array<AuthorizationPolicyStatement>> | Array<AuthorizationPolicyStatement> {
return this.policyStatements;
}
getActor(): Promise<never> {
throw new Error('Not implemented');
}
}
describe('Session.assertPerformAction', () => {

View file

@ -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<User> {
throw new AccessError('Authorization token is missing', 'UNAUTHENTICATED');
public abstract getActor(): Promise<Actor>;
/**
* 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<User> {
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<Actor> {
throw new AccessError('Authorization token is missing', 'UNAUTHENTICATED');
}
}
/**

View file

@ -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<AuthorizationPolicyStatement>;
private organizationAccessToken: OrganizationAccessToken;
readonly id: string;
constructor(
@ -20,6 +27,7 @@ export class OrganizationAccessTokenSession extends Session {
id: string;
organizationId: string;
policies: Array<AuthorizationPolicyStatement>;
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<OrganizationAccessTokenActor> {
return {
type: 'organizationAccessToken',
organizationAccessToken: this.organizationAccessToken,
};
}
protected loadPolicyStatementsForOrganization(
@ -112,6 +128,7 @@ export class OrganizationAccessTokenStrategy extends AuthNStrategy<OrganizationA
id: organizationAccessToken.id,
organizationId: organizationAccessToken.organizationId,
policies: organizationAccessToken.authorizationPolicyStatements,
organizationAccessToken,
},
{
logger: args.req.log,

View file

@ -2,13 +2,12 @@ import SessionNode from 'supertokens-node/recipe/session/index.js';
import * as zod from 'zod';
import type { FastifyReply, FastifyRequest } from '@hive/service-common';
import { captureException } from '@sentry/node';
import type { User } from '../../../shared/entities';
import { AccessError, HiveError } from '../../../shared/errors';
import { isUUID } from '../../../shared/is-uuid';
import { OrganizationMembers } from '../../organization/providers/organization-members';
import { Logger } from '../../shared/providers/logger';
import type { Storage } from '../../shared/providers/storage';
import { AuthNStrategy, AuthorizationPolicyStatement, Session } from './authz';
import { AuthNStrategy, AuthorizationPolicyStatement, Session, UserActor } from './authz';
export class SuperTokensCookieBasedSession extends Session {
public superTokensUserId: string;
@ -32,7 +31,7 @@ export class SuperTokensCookieBasedSession extends Session {
protected async loadPolicyStatementsForOrganization(
organizationId: string,
): Promise<Array<AuthorizationPolicyStatement>> {
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<User> {
public async getActor(): Promise<UserActor> {
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() {

View file

@ -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<never> {
throw new AccessError('Authorization token is missing', 'UNAUTHENTICATED');
}
}
export class TargetAccessTokenStrategy extends AuthNStrategy<TargetAccessTokenSession> {

View file

@ -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;

View file

@ -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<void | never> {
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<boolean> {
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<void | never> {
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<User> {
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;
}

View file

@ -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<OrganizationUserAccessSelector, boolean, string>;
private tokenAccess: DataLoader<OrganizationTokenAccessSelector, boolean, string>;
private allScopes: DataLoader<
OrganizationUserScopesSelector,
ReadonlyArray<OrganizationAccessScope | ProjectAccessScope | TargetAccessScope>,
string
>;
private scopes: DataLoader<
OrganizationUserScopesSelector,
readonly OrganizationAccessScope[],
string
>;
tokenInfo: DataLoader<TokenSelector, Token | null, string>;
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<void | never> {
const canAccess = await this.tokenAccess.load(selector);
if (!canAccess) {
throw new AccessError(`Missing ${selector.scope} permission`);
}
}
async ensureAccessForUser(selector: OrganizationUserAccessSelector): Promise<void | never> {
const canAccess = await this.userAccess.load(selector);
if (!canAccess) {
throw new AccessError(`Missing ${selector.scope} permission`);
}
}
async checkAccessForUser(selector: OrganizationUserAccessSelector): Promise<boolean> {
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();
}
}

View file

@ -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<ProjectUserAccessSelector, boolean, string>;
private tokenAccess: Dataloader<ProjectTokenAccessSelector, boolean, string>;
private scopes: Dataloader<ProjectUserScopesSelector, readonly ProjectAccessScope[], string>;
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<void | never> {
const canAccess = await this.tokenAccess.load(selector);
if (!canAccess) {
throw new AccessError(`Missing ${selector.scope} permission`);
}
}
async ensureAccessForUser(selector: ProjectUserAccessSelector): Promise<void | never> {
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();
}
}

View file

@ -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<TargetUserAccessSelector, boolean, string>;
private tokenAccess: Dataloader<TargetTokenAccessSelector, boolean, string>;
private scopes: Dataloader<TargetUserScopesSelector, readonly TargetAccessScope[], string>;
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<void | never> {
const canAccess = await this.tokenAccess.load(selector);
if (!canAccess) {
throw new AccessError(`Missing ${selector.scope} permission`);
}
}
async ensureAccessForUser(selector: TargetUserAccessSelector): Promise<void | never> {
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();
}
}

View file

@ -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 = {

View file

@ -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 = {

View file

@ -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 = {

View file

@ -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({

View file

@ -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';

View file

@ -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';

View file

@ -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';