import stringify from 'fast-json-stable-stringify'; import { FastifyReply, FastifyRequest } from '@hive/service-common'; import type { User } from '../../../shared/entities'; import { AccessError } from '../../../shared/errors'; import { isUUID } from '../../../shared/is-uuid'; import { Logger } from '../../shared/providers/logger'; export type AuthorizationPolicyStatement = { effect: 'allow' | 'deny'; action: ActionStrings | ActionStrings[]; resource: string | string[]; }; /** * Parses a Hive Resource identifier into an object containing a organization path and resourceId path. * e.g. `"hrn:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:target/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"` * becomes * ```json * { * "organizationId": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", * "resourceId": "target/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" * } * ``` */ function parseResourceIdentifier(resource: string) { const parts = resource.split(':'); if (parts.length < 2) { throw new Error('Invalid resource identifier (1)'); } if (parts[0] !== 'hrn') { throw new Error('Invalid resource identifier. Expected string to start with hrn: (2)'); } if (!parts[1] || (!isUUID(parts[1]) && parts[1] !== '*')) { throw new Error('Invalid resource identifier. Expected UUID or * (3)'); } const organizationId = parts[1]; if (!parts[2]) { throw new Error('Invalid resource identifier. Expected type or * (4)'); } // TODO: maybe some stricter validation of the resource id characters return { organizationId, resourceId: parts[2] }; } /** * 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. * * The `Session.loadPolicyStatementsForOrganization` method must be implemented by the subclass. */ export abstract class Session { private policyStatementCache = new Map< string, Promise | Array >(); private performActionCache = new Map>(); protected logger: Logger; constructor(args: { logger: Logger }) { this.logger = args.logger.child({ module: this.constructor.name, }); } /** Load policy statements for a specific organization. */ protected abstract loadPolicyStatementsForOrganization( organizationId: string, ): Promise> | Array; /** 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 isViewer(): boolean { return false; } /** Retrieve the access token of the request. */ public getLegacySelector(): { token: string; organizationId: string; projectId: string; targetId: string; } { throw new AccessError('Authorization header is missing'); } private async _loadPolicyStatementsForOrganization(organizationId: string) { let result = this.policyStatementCache.get(organizationId); if (result !== undefined) { return result; } result = this.loadPolicyStatementsForOrganization(organizationId); this.policyStatementCache.set(organizationId, result); return await result; } public async assertPerformAction(args: { action: TAction; organizationId: string; params: Parameters<(typeof actionDefinitions)[TAction]>[0]; }): Promise { this.logger.debug( 'Assert performing action (action=%s, organizationId=%s, params=%o)', args.action, args.organizationId, args.params, ); const argsStr = stringify(args); let result = this.performActionCache.get(argsStr); if (result !== undefined) { this.logger.debug( 'Serve result from cache (action=%s, organizationId=%s, params=%o)', args.action, args.organizationId, args.params, ); return result; } result = this._assertPerformAction(args); this.performActionCache.set(argsStr, result); return await result; } /** * Check whether a session is allowed to perform a specific action. * Throws a AccessError if the action is not allowed. */ private async _assertPerformAction(args: { action: TAction; organizationId: string; params: Parameters<(typeof actionDefinitions)[TAction]>[0]; }): Promise { const permissions = await this._loadPolicyStatementsForOrganization(args.organizationId); const resourceIdsForAction = actionDefinitions[args.action](args.params as any); let isAllowed = false; for (const permission of permissions) { const parsedResources = ( Array.isArray(permission.resource) ? permission.resource : [permission.resource] ).map(parseResourceIdentifier); /** If no resource matches, we skip this permission */ if ( !parsedResources.some(resource => { if (resource.organizationId !== '*' && resource.organizationId !== args.organizationId) { return false; } for (const resourceActionId of resourceIdsForAction) { if (isResourceIdMatch(resource.resourceId, resourceActionId)) { return true; } } return false; }) ) { continue; } const actions = Array.isArray(permission.action) ? permission.action : [permission.action]; // check if action matches for (const action of actions) { if (isActionMatch(action, args.action)) { if (permission.effect === 'deny') { this.logger.debug( 'Session not authorized to perform action. Action explicitly denied. (action=%s, organizationId=%s, params=%o)', args.action, args.organizationId, args.params, ); throw new AccessError(`Missing permission for performing '${args.action}' on resource`); } else { isAllowed = true; } } } } if (!isAllowed) { this.logger.debug( 'Session not authorized to perform action. Action not allowed. (action=%s, organizationId=%s, params=%o)', args.action, args.organizationId, args.params, ); throw new AccessError(`Missing permission for performing '${args.action}' on resource`); } } /** * Check whether a session is allowed to perform a specific action. * Returns a boolean that indicates whether the action is allowed or not allowed. */ public async canPerformAction(args: { action: TAction; organizationId: string; params: Parameters<(typeof actionDefinitions)[TAction]>[0]; }): Promise { return await this.assertPerformAction(args) .then(() => true) .catch(err => { if (err instanceof AccessError) { return false; } return Promise.reject(err); }); } /** Reset the permissions cache. */ public reset() { this.logger.debug('Reset cache.'); this.performActionCache.clear(); this.policyStatementCache.clear(); } } /** Check whether a action definition (using wildcards) matches a action */ function isActionMatch(actionContainingWildcard: string, action: string) { // any action if (actionContainingWildcard === '*') { return true; } // exact match if (actionContainingWildcard === action) { return true; } const [actionScope] = action.split(':'); const [userSpecifiedActionScope, userSpecifiedActionId] = actionContainingWildcard.split(':'); // wildcard match "scope:*" if (actionScope === userSpecifiedActionScope && userSpecifiedActionId === '*') { return true; } return false; } /** Check whether a resource id path (containing wildcards) matches a resource id path */ function isResourceIdMatch( /** The resource id path containing wildcards */ resourceIdContainingWildcards: string, /** The Resource id without wildcards */ resourceId: string, ): boolean { const wildcardIdParts = resourceIdContainingWildcards.split('/'); const resourceIdParts = resourceId.split('/'); do { const wildcardIdPart = wildcardIdParts.shift(); const resourceIdPart = resourceIdParts.shift(); if (wildcardIdPart === '*' && wildcardIdParts.length === 0) { return true; } if (wildcardIdPart !== resourceIdPart) { return false; } } while (wildcardIdParts.length || resourceIdParts.length); return true; } function defaultOrgIdentity(args: { organizationId: string }) { return [`organization/${args.organizationId}`]; } function defaultProjectIdentity( args: { projectId: string } & Parameters[0], ) { return [...defaultOrgIdentity(args), `project/${args.projectId}`]; } function defaultTargetIdentity( args: { targetId: string } & Parameters[0], ) { return [...defaultProjectIdentity(args), `target/${args.targetId}`]; } function defaultAppDeploymentIdentity( args: { appDeploymentName: string | null } & Parameters[0], ) { const ids = defaultTargetIdentity(args); if (args.appDeploymentName !== null) { ids.push(`target/${args.targetId}/appDeployment/${args.appDeploymentName}`); } return ids; } function schemaCheckOrPublishIdentity( args: { serviceName: string | null } & Parameters[0], ) { const ids = defaultTargetIdentity(args); if (args.serviceName !== null) { ids.push(`target/${args.targetId}/service/${args.serviceName}`); } return ids; } /** * Object map containing all possible actions * and resource identifier builder functions required for checking whether an action can be performed. * * Used within the `Session.assertPerformAction` function for a fully type-safe experience. * If you are adding new permissions to the existing system. * This is the place to do so. */ const actionDefinitions = { 'organization:describe': defaultOrgIdentity, 'organization:modifySlug': defaultOrgIdentity, 'organization:delete': defaultOrgIdentity, 'gitHubIntegration:modify': defaultOrgIdentity, 'slackIntegration:modify': defaultOrgIdentity, 'oidc:modify': defaultOrgIdentity, 'support:manageTickets': defaultOrgIdentity, 'billing:describe': defaultOrgIdentity, 'billing:update': defaultOrgIdentity, 'targetAccessToken:modify': defaultTargetIdentity, 'cdnAccessToken:modify': defaultTargetIdentity, 'member:describe': defaultOrgIdentity, 'member:assignRole': defaultOrgIdentity, 'member:modifyRole': defaultOrgIdentity, 'member:removeMember': defaultOrgIdentity, 'member:manageInvites': defaultOrgIdentity, 'project:create': defaultOrgIdentity, 'project:describe': defaultProjectIdentity, 'project:delete': defaultProjectIdentity, 'project:modifySettings': defaultProjectIdentity, 'alert:modify': defaultProjectIdentity, 'schemaLinting:modifyOrganizationRules': defaultOrgIdentity, 'schemaLinting:modifyProjectRules': defaultProjectIdentity, 'target:create': defaultProjectIdentity, 'target:delete': defaultTargetIdentity, 'target:modifySettings': defaultTargetIdentity, 'laboratory:describe': defaultTargetIdentity, 'laboratory:modify': defaultTargetIdentity, 'laboratory:modifyPreflightScript': defaultTargetIdentity, 'appDeployment:describe': defaultTargetIdentity, 'appDeployment:create': defaultAppDeploymentIdentity, 'appDeployment:publish': defaultAppDeploymentIdentity, 'appDeployment:retire': defaultAppDeploymentIdentity, 'schemaCheck:create': schemaCheckOrPublishIdentity, 'schemaCheck:approve': schemaCheckOrPublishIdentity, 'schemaVersion:publish': schemaCheckOrPublishIdentity, 'schemaVersion:approve': defaultTargetIdentity, 'schemaVersion:deleteService': schemaCheckOrPublishIdentity, 'schema:loadFromRegistry': defaultTargetIdentity, 'schema:compose': defaultTargetIdentity, 'auditLog:export': defaultOrgIdentity, } satisfies ActionDefinitionMap; type ActionDefinitionMap = { [key: `${string}:${string}`]: (args: any) => Array; }; type Actions = keyof typeof actionDefinitions; type ActionStrings = Actions | '*'; /** Unauthenticated session that is returned by default. */ class UnauthenticatedSession extends Session { protected loadPolicyStatementsForOrganization( _: string, ): Promise> | Array { return []; } } /** * Strategy to authenticate a session from an incoming request. * E.g. SuperTokens, JWT, etc. */ export abstract class AuthNStrategy { /** * Parse a session from an incoming request. * Returns null if the strategy does not apply to the request. * Returns a session if the strategy applies to the request. * Rejects if the strategy applies to the request but the session could not be parsed. */ public abstract parse(args: { req: FastifyRequest; reply: FastifyReply; }): Promise; } /** Helper class to Authenticate an incoming request. */ export class AuthN { private strategies: Array>; constructor(deps: { /** List of strategies for authentication a user */ strategies: Array>; }) { this.strategies = deps.strategies; } /** * Returns the first successful `Session` created by a authentication strategy. * If no authentication strategy succeeds a `UnauthenticatedSession` is returned instead. */ async authenticate(args: { req: FastifyRequest; reply: FastifyReply }): Promise { for (const strategy of this.strategies) { const session = await strategy.parse(args); if (session) { return session; } } return new UnauthenticatedSession({ logger: args.req.log, }); } }