mirror of
https://github.com/graphql-hive/console
synced 2026-04-21 14:37:17 +00:00
Co-authored-by: Kamil Kisiela <kamil.kisiela@gmail.com> Co-authored-by: Saihajpreet Singh <saihajpreet.singh@gmail.com> Co-authored-by: Laurin Quast <laurinquast@googlemail.com> Co-authored-by: Dotan Simha <dotansimha@gmail.com>
429 lines
14 KiB
TypeScript
429 lines
14 KiB
TypeScript
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<AuthorizationPolicyStatement[]> | Array<AuthorizationPolicyStatement>
|
|
>();
|
|
private performActionCache = new Map<string, Promise<void>>();
|
|
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<AuthorizationPolicyStatement>> | Array<AuthorizationPolicyStatement>;
|
|
|
|
/** 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 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<TAction extends keyof typeof actionDefinitions>(args: {
|
|
action: TAction;
|
|
organizationId: string;
|
|
params: Parameters<(typeof actionDefinitions)[TAction]>[0];
|
|
}): Promise<void> {
|
|
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<TAction extends keyof typeof actionDefinitions>(args: {
|
|
action: TAction;
|
|
organizationId: string;
|
|
params: Parameters<(typeof actionDefinitions)[TAction]>[0];
|
|
}): Promise<void> {
|
|
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<TAction extends keyof typeof actionDefinitions>(args: {
|
|
action: TAction;
|
|
organizationId: string;
|
|
params: Parameters<(typeof actionDefinitions)[TAction]>[0];
|
|
}): Promise<boolean> {
|
|
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<typeof defaultOrgIdentity>[0],
|
|
) {
|
|
return [...defaultOrgIdentity(args), `project/${args.projectId}`];
|
|
}
|
|
|
|
function defaultTargetIdentity(
|
|
args: { targetId: string } & Parameters<typeof defaultProjectIdentity>[0],
|
|
) {
|
|
return [...defaultProjectIdentity(args), `target/${args.targetId}`];
|
|
}
|
|
|
|
function defaultAppDeploymentIdentity(
|
|
args: { appDeploymentName: string | null } & Parameters<typeof defaultTargetIdentity>[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<typeof defaultTargetIdentity>[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<string>;
|
|
};
|
|
|
|
type Actions = keyof typeof actionDefinitions;
|
|
|
|
type ActionStrings = Actions | '*';
|
|
|
|
/** Unauthenticated session that is returned by default. */
|
|
class UnauthenticatedSession extends Session {
|
|
protected loadPolicyStatementsForOrganization(
|
|
_: string,
|
|
): Promise<Array<AuthorizationPolicyStatement>> | Array<AuthorizationPolicyStatement> {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Strategy to authenticate a session from an incoming request.
|
|
* E.g. SuperTokens, JWT, etc.
|
|
*/
|
|
export abstract class AuthNStrategy<TSession extends Session> {
|
|
/**
|
|
* 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<TSession | null>;
|
|
}
|
|
|
|
/** Helper class to Authenticate an incoming request. */
|
|
export class AuthN {
|
|
private strategies: Array<AuthNStrategy<Session>>;
|
|
|
|
constructor(deps: {
|
|
/** List of strategies for authentication a user */
|
|
strategies: Array<AuthNStrategy<Session>>;
|
|
}) {
|
|
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<Session> {
|
|
for (const strategy of this.strategies) {
|
|
const session = await strategy.parse(args);
|
|
if (session) {
|
|
return session;
|
|
}
|
|
}
|
|
|
|
return new UnauthenticatedSession({
|
|
logger: args.req.log,
|
|
});
|
|
}
|
|
}
|