From 04fe640487e14a16fe57214547873c52808f2cd7 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 21 Nov 2024 15:28:08 +0100 Subject: [PATCH 1/2] feat: use zod type for decoding the user --- .../schema/providers/schema-manager.ts | 5 +- .../src/modules/shared/providers/storage.ts | 6 - packages/services/api/src/shared/entities.ts | 2 +- packages/services/storage/src/index.ts | 257 +++++++----------- 4 files changed, 104 insertions(+), 166 deletions(-) diff --git a/packages/services/api/src/modules/schema/providers/schema-manager.ts b/packages/services/api/src/modules/schema/providers/schema-manager.ts index b46eb9971c..ae8100d274 100644 --- a/packages/services/api/src/modules/schema/providers/schema-manager.ts +++ b/packages/services/api/src/modules/schema/providers/schema-manager.ts @@ -969,9 +969,8 @@ export class SchemaManager { return null; } - return this.storage.getOrganizationUser({ - organizationId: args.organizationId, - userId: args.userId, + return this.storage.getUserById({ + id: args.userId, }); } diff --git a/packages/services/api/src/modules/shared/providers/storage.ts b/packages/services/api/src/modules/shared/providers/storage.ts index 54ce3119dc..6d0d075647 100644 --- a/packages/services/api/src/modules/shared/providers/storage.ts +++ b/packages/services/api/src/modules/shared/providers/storage.ts @@ -69,7 +69,6 @@ export interface Storage { isReady(): Promise; ensureUserExists(_: { superTokensUserId: string; - externalAuthUserId?: string | null; email: string; oidcIntegration: null | { id: string; @@ -848,11 +847,6 @@ export interface Storage { getTargetBreadcrumbForTargetId(_: { targetId: string }): Promise; - /** - * Get an user that belongs to a specific organization by id. - */ - getOrganizationUser(_: { organizationId: string; userId: string }): Promise; - // Zendesk setZendeskUserId(_: { userId: string; zendeskId: string }): Promise; setZendeskOrganizationId(_: { organizationId: string; zendeskId: string }): Promise; diff --git a/packages/services/api/src/shared/entities.ts b/packages/services/api/src/shared/entities.ts index dbf6e8700a..7406ef4b46 100644 --- a/packages/services/api/src/shared/entities.ts +++ b/packages/services/api/src/shared/entities.ts @@ -192,6 +192,7 @@ export interface Organization { appDeployments: boolean; }; zendeskId: string | null; + ownerId: string; } export interface OrganizationInvitation { @@ -330,7 +331,6 @@ export interface User { provider: AuthProvider; superTokensUserId: string | null; isAdmin: boolean; - externalAuthUserId: string | null; oidcIntegrationId: string | null; zendeskId: string | null; } diff --git a/packages/services/storage/src/index.ts b/packages/services/storage/src/index.ts index b858813c9d..4108d2f367 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -7,12 +7,11 @@ import { UniqueIntegrityConstraintViolationError, } from 'slonik'; import { update } from 'slonik-utilities'; -import { TransactionFunction } from 'slonik/dist/src/types'; +import { TaggedTemplateLiteralInvocation, TransactionFunction } from 'slonik/dist/src/types'; import zod from 'zod'; import type { Alert, AlertChannel, - AuthProvider, Member, Organization, OrganizationBilling, @@ -21,7 +20,6 @@ import type { Schema, Storage, TargetSettings, - User, } from '@hive/api'; import { context, SpanKind, SpanStatusCode, trace } from '@hive/service-common'; import type { SchemaCoordinatesDiffResult } from '../../api/src/modules/schema/providers/inspector'; @@ -139,37 +137,6 @@ async function tracedTransaction( }); } -function resolveAuthProviderOfUser( - user: users & { - provider: string | null | undefined; - }, -): AuthProvider { - // TODO: remove this once we have migrated all users - if (user.external_auth_user_id) { - if (user.external_auth_user_id.startsWith('github')) { - return 'GITHUB'; - } - - if (user.external_auth_user_id.startsWith('google')) { - return 'GOOGLE'; - } - - return 'USERNAME_PASSWORD'; - } - - if (user.provider === 'oidc') { - return 'OIDC'; - } - if (user.provider === 'google') { - return 'GOOGLE'; - } - if (user.provider === 'github') { - return 'GITHUB'; - } - - return 'USERNAME_PASSWORD'; -} - type MemberRoleColumns = | { role_id: organization_member_roles['id']; @@ -193,25 +160,6 @@ export async function createStorage( ): Promise { const pool = await getPool(connection, maximumPoolSize, additionalInterceptors); - function transformUser( - user: users & { - provider: string | null | undefined; - }, - ): User { - return { - id: user.id, - email: user.email, - superTokensUserId: user.supertoken_user_id, - provider: resolveAuthProviderOfUser(user), - fullName: user.full_name, - displayName: user.display_name, - isAdmin: user.is_admin ?? false, - externalAuthUserId: user.external_auth_user_id ?? null, - oidcIntegrationId: user.oidc_integration_id ?? null, - zendeskId: user.zendesk_user_id ?? null, - }; - } - function transformSchemaPolicy(schema_policy: schema_policy_config): SchemaPolicy { return { id: `${schema_policy.resource_type}_${schema_policy.resource_id}`, @@ -235,7 +183,7 @@ export async function createStorage( return { id: user.id, isOwner: user.is_owner, - user: transformUser(user), + user: UserModel.parse(user), // This allows us to have a fallback for users that don't have a role, remove this once we all users have a role scopes: (user.scopes as Member['scopes']) || [], organization: user.organization_id, @@ -276,6 +224,7 @@ export async function createStorage( }, featureFlags: decodeFeatureFlags(organization.feature_flags), zendeskId: organization.zendesk_organization_id ?? null, + ownerId: organization.user_id, }; } @@ -477,27 +426,23 @@ export async function createStorage( { superTokensUserId }: { superTokensUserId: string }, connection: Connection, ) { - const user = await connection.maybeOne< - users & { - provider: string | null; - } - >(sql`/* getUserBySuperTokenId */ + const record = await connection.maybeOne(sql`/* getUserBySuperTokenId */ SELECT - u.*, - stu.third_party_id as provider + ${userFields(sql`"users".`, sql`"stu".`)} FROM - users as u - LEFT JOIN supertokens_thirdparty_users as stu ON (stu.user_id = u.supertoken_user_id) + "users" + LEFT JOIN "supertokens_thirdparty_users" AS "stu" + ON ("stu"."user_id" = "users"."supertoken_user_id") WHERE - u.supertoken_user_id = ${superTokensUserId} + "users"."supertoken_user_id" = ${superTokensUserId} LIMIT 1 `); - if (user) { - return transformUser(user); + if (!record) { + return null; } - return null; + return UserModel.parse(record); }, async createUser( { @@ -505,36 +450,31 @@ export async function createStorage( email, fullName, displayName, - externalAuthUserId, oidcIntegrationId, }: { superTokensUserId: string; email: string; fullName: string; displayName: string; - externalAuthUserId: string | null; oidcIntegrationId: string | null; }, connection: Connection, ) { - const user = await connection.one( + await connection.query( sql`/* createUser */ INSERT INTO users - ("email", "supertoken_user_id", "full_name", "display_name", "external_auth_user_id", "oidc_integration_id") + ("email", "supertoken_user_id", "full_name", "display_name", "oidc_integration_id") VALUES - (${email}, ${superTokensUserId}, ${fullName}, ${displayName}, ${externalAuthUserId}, ${oidcIntegrationId}) - RETURNING * + (${email}, ${superTokensUserId}, ${fullName}, ${displayName}, ${oidcIntegrationId}) `, ); - const provider = await connection.maybeOneFirst(sql`/* findSupertokensProvider */ - SELECT third_party_id FROM supertokens_thirdparty_users WHERE user_id = ${superTokensUserId} LIMIT 1 - `); + const user = await this.getUserBySuperTokenId({ superTokensUserId }, connection); + if (!user) { + throw new Error('Something went wrong.'); + } - return transformUser({ - ...user, - provider, - }); + return user; }, async getOrganization(userId: string, connection: Connection) { const org = await connection.maybeOne>( @@ -602,7 +542,6 @@ export async function createStorage( function buildUserData(input: { superTokensUserId: string; email: string; - externalAuthUserId: string | null; oidcIntegrationId: string | null; firstName: string | null; lastName: string | null; @@ -618,7 +557,6 @@ export async function createStorage( email: input.email, displayName: name, fullName: name, - externalAuthUserId: input.externalAuthUserId, oidcIntegrationId: input.oidcIntegrationId, }; } @@ -637,14 +575,12 @@ export async function createStorage( }, async ensureUserExists({ superTokensUserId, - externalAuthUserId, email, oidcIntegration, firstName, lastName, }: { superTokensUserId: string; - externalAuthUserId?: string | null; firstName: string | null; lastName: string | null; email: string; @@ -661,7 +597,6 @@ export async function createStorage( buildUserData({ superTokensUserId, email, - externalAuthUserId: externalAuthUserId ?? null, oidcIntegrationId: oidcIntegration?.id ?? null, firstName, lastName, @@ -691,52 +626,42 @@ export async function createStorage( }, getUserById: batch(async input => { const userIds = input.map(i => i.id); - const users = await pool.any< - users & { - provider: string | null; - } - >(sql`/* getUserById */ + const records = await pool.any(sql`/* getUserById */ SELECT - u.*, stu.third_party_id as provider + ${userFields(sql`"users".`, sql`"stu".`)} FROM - "users" as u - LEFT JOIN - supertokens_thirdparty_users as stu ON (stu.user_id = u.supertoken_user_id) + "users" + LEFT JOIN "supertokens_thirdparty_users" AS "stu" + ON ("stu"."user_id" = "users"."supertoken_user_id") WHERE - u.id = ANY(${sql.array(userIds, 'uuid')}) + "users"."id" = ANY(${sql.array(userIds, 'uuid')}) `); - const mappings = new Map< - string, - users & { - provider: string | null; - } - >(); - for (const user of users) { + const mappings = new Map(); + for (const record of records) { + const user = UserModel.parse(record); mappings.set(user.id, user); } - return userIds.map(id => { - const user = mappings.get(id) ?? null; - return Promise.resolve(user ? transformUser(user) : null); - }); + return userIds.map(async id => mappings.get(id) ?? null); }), async updateUser({ id, displayName, fullName }) { - const user = await pool.one(sql`/* updateUser */ + await pool.one(sql`/* updateUser */ UPDATE "users" - SET display_name = ${displayName}, full_name = ${fullName} - WHERE id = ${id} - RETURNING * + SET + "display_name" = ${displayName} + , "full_name" = ${fullName} + WHERE + "id" = ${id} `); - const provider = await pool.maybeOneFirst(sql`/* findSupertokensProvider */ - SELECT third_party_id FROM supertokens_thirdparty_users WHERE user_id = ${user.supertoken_user_id} LIMIT 1 - `); + const user = await this.getUserById({ id }); - return transformUser({ - ...user, - provider, - }); + if (!user) { + throw new Error('Something went wrong.'); + } + + return user; }, createOrganization(input) { return tracedTransaction('createOrganization', pool, async t => { @@ -915,7 +840,7 @@ export async function createStorage( >( sql`/* getOrganizationOwner */ SELECT - u.*, + ${userFields(sql`"u".`, sql`"stu".`)}, COALESCE(omr.scopes, om.scopes) as scopes, om.organization_id, om.connected_to_zendesk, @@ -923,14 +848,13 @@ export async function createStorage( omr.name as role_name, omr.locked as role_locked, omr.scopes as role_scopes, - omr.description as role_description, - stu.third_party_id as provider + omr.description as role_description FROM organizations as o LEFT JOIN users as u ON (u.id = o.user_id) LEFT JOIN organization_member as om ON (om.user_id = u.id AND om.organization_id = o.id) LEFT JOIN organization_member_roles as omr ON (omr.organization_id = o.id AND omr.id = om.role_id) LEFT JOIN supertokens_thirdparty_users as stu ON (stu.user_id = u.supertoken_user_id) - WHERE o.id IN (${sql.join(organizations, sql`, `)})`, + WHERE o.id = ANY(${sql.array(organizations, 'uuid')})`, ); return organizations.map(organization => { @@ -967,7 +891,7 @@ export async function createStorage( >( sql`/* getOrganizationMembers */ SELECT - u.*, + ${userFields(sql`"u".`, sql`"stu".`)}, COALESCE(omr.scopes, om.scopes) as scopes, om.organization_id, om.connected_to_zendesk, @@ -976,16 +900,15 @@ export async function createStorage( omr.name as role_name, omr.locked as role_locked, omr.scopes as role_scopes, - omr.description as role_description, - stu.third_party_id as provider + omr.description as role_description FROM organization_member as om LEFT JOIN organizations as o ON (o.id = om.organization_id) LEFT JOIN users as u ON (u.id = om.user_id) LEFT JOIN organization_member_roles as omr ON (omr.organization_id = o.id AND omr.id = om.role_id) LEFT JOIN supertokens_thirdparty_users as stu ON (stu.user_id = u.supertoken_user_id) - WHERE om.organization_id IN (${sql.join( + WHERE om.organization_id = ANY(${sql.array( organizations, - sql`, `, + 'uuid', )}) ORDER BY u.created_at DESC`, ); @@ -1010,7 +933,7 @@ export async function createStorage( >( sql`/* getOrganizationMember */ SELECT - u.*, + ${userFields(sql`"u".`, sql`"stu".`)}, COALESCE(omr.scopes, om.scopes) as scopes, om.organization_id, om.connected_to_zendesk, @@ -1019,8 +942,7 @@ export async function createStorage( omr.name as role_name, omr.locked as role_locked, omr.scopes as role_scopes, - omr.description as role_description, - stu.third_party_id as provider + omr.description as role_description FROM organization_member as om LEFT JOIN organizations as o ON (o.id = om.organization_id) LEFT JOIN users as u ON (u.id = om.user_id) @@ -1178,7 +1100,7 @@ export async function createStorage( >( sql`/* getMembersWithoutRole */ SELECT - u.*, + ${userFields(sql`"u".`, sql`"stu".`)}, COALESCE(omr.scopes, om.scopes) as scopes, om.organization_id, om.connected_to_zendesk, @@ -1187,8 +1109,7 @@ export async function createStorage( omr.name as role_name, omr.locked as role_locked, omr.scopes as role_scopes, - omr.description as role_description, - stu.third_party_id as provider + omr.description as role_description FROM organization_member as om LEFT JOIN organizations as o ON (o.id = om.organization_id) LEFT JOIN users as u ON (u.id = om.user_id) @@ -4378,30 +4299,6 @@ export async function createStorage( return TargetBreadcrumbModel.parse(result); }, - async getOrganizationUser(args) { - const result = await pool.maybeOne< - users & { - provider: string | null; - } - >(sql`/* getOrganizationUser */ - SELECT - "u".*, "stu"."third_party_id" as provider - FROM "organization_member" as "om" - LEFT JOIN "organizations" as "o" ON ("o"."id" = "om"."organization_id") - LEFT JOIN "users" as "u" ON ("u"."id" = "om"."user_id") - LEFT JOIN "supertokens_thirdparty_users" as "stu" ON ("stu"."user_id" = "u"."supertoken_user_id") - WHERE - "u"."id" = ${args.userId} - AND "o"."id" = ${args.organizationId} - `); - - if (result === null) { - return null; - } - - return transformUser(result); - }, - async updateTargetGraphQLEndpointUrl(args) { const result = await pool.maybeOne(sql`/* updateTargetGraphQLEndpointUrl */ UPDATE @@ -5305,3 +5202,51 @@ export type PaginatedSchemaVersionConnection = Readonly<{ endCursor: string; }>; }>; + +export const userFields = ( + user: TaggedTemplateLiteralInvocation, + superTokensThirdParty: TaggedTemplateLiteralInvocation, +) => sql` + ${user}"id" + , ${user}"email" + , to_json(${user}"created_at") AS "createdAt" + , ${user}"display_name" AS "displayName" + , ${user}"full_name" AS "fullName" + , ${user}"supertoken_user_id" AS "superTokensUserId" + , ${user}"is_admin" AS "isAdmin" + , ${user}"oidc_integration_id" AS "oidcIntegrationId" + , ${user}"zendesk_user_id" AS "zendeskId" + , ${superTokensThirdParty}"third_party_id" AS "provider" +`; + +export const UserModel = zod.object({ + id: zod.string(), + email: zod.string(), + createdAt: zod.string(), + displayName: zod.string(), + fullName: zod.string(), + superTokensUserId: zod.string(), + isAdmin: zod + .boolean() + .nullable() + .transform(value => value ?? false), + oidcIntegrationId: zod.string().nullable(), + zendeskId: zod.string().nullable(), + provider: zod + .string() + .nullable() + .transform(provider => { + if (provider === 'oidc') { + return 'OIDC' as const; + } + if (provider === 'google') { + return 'GOOGLE' as const; + } + if (provider === 'github') { + return 'GITHUB' as const; + } + return 'USERNAME_PASSWORD' as const; + }), +}); + +type UserType = zod.TypeOf; From bdcca75029a23672e58fd618e10501343be4f0b6 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 27 Nov 2024 14:50:37 +0100 Subject: [PATCH 2/2] wip --- ...11.21T00-00-00.member-rules-permissions.ts | 19 + packages/migrations/src/run-pg-migrations.ts | 1 + .../services/api/src/modules/auth/index.ts | 10 +- .../api/src/modules/auth/lib/authz.ts | 2 +- .../modules/auth/lib/supertokens-strategy.ts | 277 +++---- .../auth/providers/organization-members.ts | 717 ++++++++++++++++++ packages/services/server/src/index.ts | 2 + tsconfig.json | 3 + 8 files changed, 875 insertions(+), 156 deletions(-) create mode 100644 packages/migrations/src/actions/2024.11.21T00-00-00.member-rules-permissions.ts create mode 100644 packages/services/api/src/modules/auth/providers/organization-members.ts diff --git a/packages/migrations/src/actions/2024.11.21T00-00-00.member-rules-permissions.ts b/packages/migrations/src/actions/2024.11.21T00-00-00.member-rules-permissions.ts new file mode 100644 index 0000000000..8118698eae --- /dev/null +++ b/packages/migrations/src/actions/2024.11.21T00-00-00.member-rules-permissions.ts @@ -0,0 +1,19 @@ +import { type MigrationExecutor } from '../pg-migrator'; + +export default { + name: '2024.11.21T00-00-00.member-rules-permissions.ts', + run: ({ sql }) => sql` + ALTER TABLE "organization_member_roles" + ALTER "scopes" DROP NOT NULL + , ADD COLUMN "permissions_groups" JSONB + ; + + CREATE TABLE "organization_member_role_assignments" ( + "organization_id" UUID NOT NULL REFERENCES "organizations" ("id") ON DELETE CASCADE + , "user_id" UUID NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE + , "organization_member_role_id" UUID NOT NULL REFERENCES "organization_member_roles" ("id") ON DELETE CASCADE + , "resources" JSONB + PRIMARY KEY ("organization_id", "user_id", "organization_member_role_id") + ); + `, +} satisfies MigrationExecutor; diff --git a/packages/migrations/src/run-pg-migrations.ts b/packages/migrations/src/run-pg-migrations.ts index b5399c2f7c..b9c56fca09 100644 --- a/packages/migrations/src/run-pg-migrations.ts +++ b/packages/migrations/src/run-pg-migrations.ts @@ -147,5 +147,6 @@ export const runPGMigrations = async (args: { slonik: DatabasePool; runTo?: stri await import('./actions/2024.11.12T00-00-00.supertokens-9.1'), await import('./actions/2024.11.12T00-00-00.supertokens-9.2'), await import('./actions/2024.11.12T00-00-00.supertokens-9.3'), + await import('./actions/2024.11.21T00-00-00.member-rules-permissions'), ], }); diff --git a/packages/services/api/src/modules/auth/index.ts b/packages/services/api/src/modules/auth/index.ts index c640d657a2..7b397000cf 100644 --- a/packages/services/api/src/modules/auth/index.ts +++ b/packages/services/api/src/modules/auth/index.ts @@ -1,6 +1,7 @@ import { createModule } from 'graphql-modules'; import { AuthManager } from './providers/auth-manager'; import { OrganizationAccess } from './providers/organization-access'; +import { OrganizationMembers } from './providers/organization-members'; import { ProjectAccess } from './providers/project-access'; import { TargetAccess } from './providers/target-access'; import { UserManager } from './providers/user-manager'; @@ -12,5 +13,12 @@ export const authModule = createModule({ dirname: __dirname, typeDefs, resolvers, - providers: [AuthManager, UserManager, OrganizationAccess, ProjectAccess, TargetAccess], + providers: [ + AuthManager, + UserManager, + OrganizationAccess, + ProjectAccess, + TargetAccess, + OrganizationMembers, + ], }); diff --git a/packages/services/api/src/modules/auth/lib/authz.ts b/packages/services/api/src/modules/auth/lib/authz.ts index af58d3b3c8..3979234b74 100644 --- a/packages/services/api/src/modules/auth/lib/authz.ts +++ b/packages/services/api/src/modules/auth/lib/authz.ts @@ -369,7 +369,7 @@ type ActionDefinitionMap = { type Actions = keyof typeof actionDefinitions; -type ActionStrings = Actions | '*'; +export type ActionStrings = Actions | '*'; /** Unauthenticated session that is returned by default. */ class UnauthenticatedSession extends Session { diff --git a/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts b/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts index eadbcaaf30..d3710dda0e 100644 --- a/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts +++ b/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts @@ -8,22 +8,23 @@ import { isUUID } from '../../../shared/is-uuid'; import { Logger } from '../../shared/providers/logger'; import type { Storage } from '../../shared/providers/storage'; import { - OrganizationAccessScope, - ProjectAccessScope, - TargetAccessScope, -} from '../providers/scopes'; -import { AuthNStrategy, AuthorizationPolicyStatement, Session } from './authz'; + OrganizationMembers, + OrganizationMembershipRoleAssignment, +} from '../providers/organization-members'; +import { ActionStrings, AuthNStrategy, AuthorizationPolicyStatement, Session } from './authz'; export class SuperTokensCookieBasedSession extends Session { public superTokensUserId: string; + private organizationMembers: OrganizationMembers; private storage: Storage; constructor( args: { superTokensUserId: string; email: string }, - deps: { storage: Storage; logger: Logger }, + deps: { organizationMembers: OrganizationMembers; storage: Storage; logger: Logger }, ) { super({ logger: deps.logger }); this.superTokensUserId = args.superTokensUserId; + this.organizationMembers = deps.organizationMembers; this.storage = deps.storage; } @@ -32,17 +33,54 @@ export class SuperTokensCookieBasedSession extends Session { ): Promise> { const user = await this.getViewer(); + this.logger.debug( + 'Loading policy statements for organization. (userId=%s, organizationId=%s)', + user.id, + organizationId, + ); + if (!isUUID(organizationId)) { + this.logger.debug( + 'Invalid organization ID provided. (userId=%s, organizationId=%s)', + user.id, + organizationId, + ); + return []; } - const member = await this.storage.getOrganizationMember({ + this.logger.debug( + 'Load organization membership for user. (userId=%s, organizationId=%s)', + user.id, organizationId, + ); + + const organization = await this.storage.getOrganization({ organizationId }); + const organizationMembership = await this.organizationMembers.findOrganizationMembership({ + organization, userId: user.id, }); + console.log(JSON.stringify(organizationMembership, null, 2)); + + if (!organizationMembership) { + this.logger.debug( + 'No membership found, resolve empty policy statements. (userId=%s, organizationId=%s)', + user.id, + organizationId, + ); + + return []; + } + // owner of organization should have full right to do anything. - if (member?.isOwner) { + if (organizationMembership.isAdmin) { + this.logger.debug( + 'User is organization owner, resolve admin access policy. (userId=%s, organizationId=%s)', + user.id, + organizationId, + ); + return [ { action: '*', @@ -52,11 +90,76 @@ export class SuperTokensCookieBasedSession extends Session { ]; } - if (Array.isArray(member?.scopes)) { - return transformOrganizationMemberLegacyScopes({ organizationId, scopes: member.scopes }); + this.logger.debug( + 'Translate organization role assignments to policy statements. (userId=%s, organizationId=%s)', + user.id, + organizationId, + ); + + const policyStatements = this.translateAssignedRolesToAuthorizationPolicyStatements( + organizationId, + organizationMembership.assignedRoles, + ); + + return policyStatements; + } + + private translateAssignedRolesToAuthorizationPolicyStatements( + organizationId: string, + organizationMembershipRoleAssignments: Array, + ): Array { + const policyStatements: Array = []; + + for (const assignedRole of organizationMembershipRoleAssignments) { + for (const permissionGroup of assignedRole.role.permissionGroups) { + const resources = assignedRole.resources[permissionGroup.resourceType]; + + if (!resources) { + // skip is assignment group is missing + continue; + } + + const resourceIds: Array = []; + + switch (permissionGroup.resourceType) { + case 'organization': + case 'project': + case 'target': { + if (resources === '*') { + resourceIds.push(`hrn:${organizationId}:${permissionGroup.resourceType}/*`); + break; + } + resourceIds.push( + ...resources.map( + resourceId => `hrn:${organizationId}:${permissionGroup.resourceType}/${resourceId}`, + ), + ); + break; + } + case 'service': { + if (resources === '*') { + resourceIds.push(`hrn:${organizationId}:target/*/service/*`); + break; + } + resourceIds.push( + ...resources.map(resourceId => { + const [targetId, serviceName] = resourceId.split('/'); + return `hrn:${organizationId}:${targetId}/service/${serviceName}`; + }), + ); + break; + } + } + + policyStatements.push({ + action: permissionGroup.permissions as ActionStrings[], + effect: permissionGroup.effect, + resource: resourceIds, + }); + } } - return []; + return policyStatements; } public async getViewer(): Promise { @@ -78,11 +181,17 @@ export class SuperTokensCookieBasedSession extends Session { export class SuperTokensUserAuthNStrategy extends AuthNStrategy { private logger: ServiceLogger; + private organizationMembers: OrganizationMembers; private storage: Storage; - constructor(deps: { logger: ServiceLogger; storage: Storage }) { + constructor(deps: { + logger: ServiceLogger; + organizationMembers: OrganizationMembers; + storage: Storage; + }) { super(); this.logger = deps.logger.child({ module: 'SuperTokensUserAuthNStrategy' }); + this.organizationMembers = deps.organizationMembers; this.storage = deps.storage; } @@ -172,8 +281,9 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy; -}) { - const policies: Array = []; - for (const scope of args.scopes) { - switch (scope) { - case OrganizationAccessScope.READ: { - policies.push({ - effect: 'allow', - action: [ - 'support:manageTickets', - 'project:create', - 'project:describe', - 'organization:describe', - ], - resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`], - }); - break; - } - case OrganizationAccessScope.SETTINGS: { - policies.push({ - effect: 'allow', - action: [ - 'organization:modifySlug', - 'schemaLinting:modifyOrganizationRules', - 'billing:describe', - 'billing:update', - ], - resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`], - }); - break; - } - case OrganizationAccessScope.DELETE: { - policies.push({ - effect: 'allow', - action: ['organization:delete'], - resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`], - }); - break; - } - case OrganizationAccessScope.INTEGRATIONS: { - policies.push({ - effect: 'allow', - action: ['oidc:modify', 'gitHubIntegration:modify', 'slackIntegration:modify'], - resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`], - }); - break; - } - case OrganizationAccessScope.MEMBERS: { - policies.push({ - effect: 'allow', - action: [ - 'member:manageInvites', - 'member:removeMember', - 'member:assignRole', - 'member:modifyRole', - 'member:describe', - ], - resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`], - }); - break; - } - case ProjectAccessScope.ALERTS: { - policies.push({ - effect: 'allow', - action: ['alert:modify'], - resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`], - }); - break; - } - case ProjectAccessScope.READ: { - policies.push({ - effect: 'allow', - action: ['project:describe'], - resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`], - }); - break; - } - case ProjectAccessScope.DELETE: { - policies.push({ - effect: 'allow', - action: ['project:delete'], - resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`], - }); - break; - } - case ProjectAccessScope.SETTINGS: { - policies.push({ - effect: 'allow', - action: ['project:delete', 'project:modifySettings', 'schemaLinting:modifyProjectRules'], - resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`], - }); - break; - } - case TargetAccessScope.READ: { - policies.push({ - effect: 'allow', - action: ['appDeployment:describe', 'laboratory:describe', 'target:create'], - resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`], - }); - break; - } - case TargetAccessScope.REGISTRY_WRITE: { - policies.push({ - effect: 'allow', - action: ['schemaCheck:approve', 'schemaVersion:approve', 'laboratory:modify'], - resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`], - }); - break; - } - case TargetAccessScope.TOKENS_WRITE: { - policies.push({ - effect: 'allow', - action: ['targetAccessToken:modify', 'cdnAccessToken:modify'], - resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`], - }); - break; - } - case TargetAccessScope.SETTINGS: { - policies.push({ - effect: 'allow', - action: ['target:modifySettings'], - resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`], - }); - break; - } - case TargetAccessScope.DELETE: { - policies.push({ - effect: 'allow', - action: ['target:delete'], - resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`], - }); - break; - } - } - } - - return policies; -} diff --git a/packages/services/api/src/modules/auth/providers/organization-members.ts b/packages/services/api/src/modules/auth/providers/organization-members.ts new file mode 100644 index 0000000000..c524392ee9 --- /dev/null +++ b/packages/services/api/src/modules/auth/providers/organization-members.ts @@ -0,0 +1,717 @@ +import { Inject, Injectable, Scope } from 'graphql-modules'; +import { sql, type DatabasePool } from 'slonik'; +import zod from 'zod'; +import { + Organization, + OrganizationAccessScope, + ProjectAccessScope, + TargetAccessScope, +} from '@hive/api'; +import { batch, batchBy } from '../../../shared/helpers'; +import { isUUID } from '../../../shared/is-uuid'; +import { Logger } from '../../shared/providers/logger'; +import { PG_POOL_CONFIG } from '../../shared/providers/pg-pool'; + +const RawOrganizationMembershipModel = zod.object({ + userId: zod.string(), + /** Legacy scopes on membership, way of assigning permissions before the introduction of roles */ + legacyScopes: zod + .array(zod.string()) + .transform( + value => value as Array, + ) + .nullable(), + /** Legacy role id, way of assigning permissions via a role before the introduction of assigning multiple roles */ + legacyRoleId: zod.string().nullable(), +}); + +type RawOrganizationMembershipType = zod.TypeOf; + +const ResourceTypeModel = zod.enum(['organization', 'project', 'target', 'service']); +const EffectModel = zod.enum(['allow', 'deny']); + +/** Group of permissions that will be applied on the specified resource type level. */ +const PermissionGroupModel = zod.object({ + resourceType: ResourceTypeModel, + permissions: zod.array(zod.string()), + effect: EffectModel, +}); + +type PermissionGroupType = zod.TypeOf; + +const RawMemberRoleModel = zod.intersection( + zod.object({ + id: zod.string(), + description: zod.string(), + isLocked: zod.boolean(), + }), + zod.union([ + zod.object({ + legacyScopes: zod + .array(zod.string()) + .transform( + value => value as Array, + ), + permissionGroups: zod.null(), + }), + zod.object({ + legacyScopes: zod.null(), + permissionGroups: zod.array(PermissionGroupModel), + }), + ]), +); + +const UUIDResourceAssignmentModel = zod + .union([zod.literal('*'), zod.array(zod.string().uuid())]) + .optional() + .nullable() + .transform(value => value ?? null); + +/** + * String in the form `targetId/serviceName` + * Example: `f81ce726-2abf-4653-bf4c-d8436cde255a/users` + */ +const ServiceResourceAssignmentStringModel = zod.string().refine(value => { + const [targetId, serviceName = ''] = value.split('/'); + if (isUUID(targetId) === false || serviceName === '') { + return false; + } + return true; +}, 'Invalid service resource assignment'); + +const ServiceResourceAssignmentModel = zod + .union([zod.literal('*'), zod.array(ServiceResourceAssignmentStringModel)]) + .optional() + .nullable() + .transform(value => value ?? null); + +const ResourceAssignmentGroupModel = zod.object({ + /** Resources assigned to a 'organization' permission group */ + organization: UUIDResourceAssignmentModel, + /** Resources assigned to a 'projects' permission group */ + project: UUIDResourceAssignmentModel, + /** Resources assigned to a 'targets' permission group */ + target: UUIDResourceAssignmentModel, + /** Resources assigned to a 'service' permission group */ + service: ServiceResourceAssignmentModel, +}); + +type ResourceAssignmentGroup = zod.TypeOf; + +const RawRoleAssignmentModel = zod.object({ + userId: zod.string(), + organizationMemberRoleId: zod.string(), + /** + * The resources that will be assigned to each permission group based on their resource typ. + */ + resources: ResourceAssignmentGroupModel, +}); + +type MemberRoleType = { + id: string; + description: string; + isLocked: boolean; + permissionGroups: Array; +}; + +export type OrganizationMembershipRoleAssignment = { + role: MemberRoleType; + resources: ResourceAssignmentGroup; +}; + +type OrganizationMembership = { + organizationId: string; + isAdmin: boolean; + userId: string; + assignedRoles: Array; + /** + * legacy role assigned to this membership. + * Note: The role is already resolved to a "OrganizationMembershipRoleAssignment" within the assignedRoles property. + **/ + legacyRoleId: string | null; + /** + * Legacy scope assigned to this membership. + * Note: They are already resolved to a "OrganizationMembershipRoleAssignment" within the assignedRoles property. + **/ + legacyScopes: Array | null; +}; + +function transformOrganizationMemberLegacyScopesIntoPermissionGroup( + scopes: Array, +): PermissionGroupType { + const permissionGroup: PermissionGroupType = { + effect: 'allow', + resourceType: 'organization', + permissions: [], + }; + for (const scope of scopes) { + switch (scope) { + case OrganizationAccessScope.READ: { + permissionGroup.permissions.push( + 'support:manageTickets', + 'project:create', + 'project:describe', + 'organization:describe', + ); + break; + } + case OrganizationAccessScope.SETTINGS: { + permissionGroup.permissions.push( + 'organization:modifySlug', + 'schemaLinting:modifyOrganizationRules', + 'billing:describe', + 'billing:update', + ); + break; + } + case OrganizationAccessScope.DELETE: { + permissionGroup.permissions.push('organization:delete'); + break; + } + case OrganizationAccessScope.INTEGRATIONS: { + permissionGroup.permissions.push( + 'oidc:modify', + 'gitHubIntegration:modify', + 'slackIntegration:modify', + ); + break; + } + case OrganizationAccessScope.MEMBERS: { + permissionGroup.permissions.push( + 'member:manageInvites', + 'member:removeMember', + 'member:assignRole', + 'member:modifyRole', + 'member:describe', + ); + break; + } + case ProjectAccessScope.ALERTS: { + permissionGroup.permissions.push('alert:modify'); + break; + } + case ProjectAccessScope.READ: { + permissionGroup.permissions.push('project:describe'); + break; + } + case ProjectAccessScope.DELETE: { + permissionGroup.permissions.push('project:delete'); + break; + } + case ProjectAccessScope.SETTINGS: { + permissionGroup.permissions.push( + 'project:delete', + 'project:modifySettings', + 'schemaLinting:modifyProjectRules', + ); + break; + } + case TargetAccessScope.READ: { + permissionGroup.permissions.push( + 'appDeployment:describe', + 'laboratory:describe', + 'target:create', + ); + break; + } + case TargetAccessScope.REGISTRY_WRITE: { + permissionGroup.permissions.push( + 'schemaCheck:approve', + 'schemaVersion:approve', + 'laboratory:modify', + ); + break; + } + case TargetAccessScope.TOKENS_WRITE: { + permissionGroup.permissions.push('targetAccessToken:modify', 'cdnAccessToken:modify'); + break; + } + case TargetAccessScope.SETTINGS: { + permissionGroup.permissions.push('target:modifySettings'); + break; + } + case TargetAccessScope.DELETE: { + permissionGroup.permissions.push('target:delete'); + break; + } + } + } + + return permissionGroup; +} + +@Injectable({ + scope: Scope.Operation, +}) +export class OrganizationMembers { + private logger: Logger; + constructor( + @Inject(PG_POOL_CONFIG) private pool: DatabasePool, + logger: Logger, + ) { + this.logger = logger.child({ + source: 'OrganizationMembers', + }); + } + + /** Find member roles by their ID */ + private findMemberRolesByIds = async (roleIds: Array) => { + this.logger.debug('Find organization membership roles. (roleIds=%o)', roleIds); + + const query = sql` + SELECT + "id" + , "name" + , "description" + , "locked" AS "isLocked" + , "scopes" AS "legacyScopes" + , "permissions_groups" AS "permissionGroups" + FROM + "organization_member_roles" + WHERE + "id" = ANY(${sql.array(roleIds, 'uuid')}) + `; + + const result = await this.pool.any(query); + + const rowsById = new Map(); + + for (const row of result) { + const record = RawMemberRoleModel.parse(row); + + rowsById.set(record.id, { + id: record.id, + isLocked: record.isLocked, + description: record.description, + permissionGroups: + record.permissionGroups !== null + ? record.permissionGroups + : // In case the role has legacy scopes attached, we automatically translate them to permission groups + [transformOrganizationMemberLegacyScopesIntoPermissionGroup(record.legacyScopes)], + }); + } + return rowsById; + }; + + private findMemberRoleById = batch(async (roleIds: Array) => { + const rolesById = await this.findMemberRolesByIds(roleIds); + + return roleIds.map(async roleId => rolesById.get(roleId) ?? null); + }); + + /** Find all the role assignments for specific users within an organization as a convenient lookup map. */ + private async findRoleAssignmentsForUsersInOrganization( + organizationId: string, + userIds: Array, + ): Promise>> { + this.logger.debug( + 'Find organization role assignments for users within organization. (organizationId=%s, userIds=%o)', + organizationId, + userIds, + ); + + const query = sql` + SELECT + "user_id" AS "userId" + , "organization_member_role_id" AS "organizationMemberRoleId" + , "resources" + FROM + "organization_member_role_assignments" AS "role_assignments" + WHERE + "organization_id" = ${organizationId} + AND "user_id" = ANY${sql.array(userIds, 'uuid')} + `; + + const result = await this.pool.any(query); + + const roleAssignmentsByUserId = new Map< + string, + Array<{ + organizationMemberRoleId: string; + resources: ResourceAssignmentGroup; + }> + >(); + + const pendingMemberRoleIdLookups = new Set(); + + for (const row of result) { + const record = RawRoleAssignmentModel.parse(row); + let roleAssignmentsForUser = roleAssignmentsByUserId.get(record.userId); + if (!roleAssignmentsForUser) { + roleAssignmentsForUser = []; + roleAssignmentsByUserId.set(record.userId, roleAssignmentsForUser); + } + + roleAssignmentsForUser.push({ + resources: record.resources, + organizationMemberRoleId: record.organizationMemberRoleId, + }); + pendingMemberRoleIdLookups.add(record.organizationMemberRoleId); + } + + const memberRoleById = await this.findMemberRolesByIds(Array.from(pendingMemberRoleIdLookups)); + + return new Map( + roleAssignmentsByUserId.entries().map( + ([userId, assignedRoles]) => + [ + userId, + assignedRoles.map(assignedRole => { + const memberRole = memberRoleById.get(assignedRole.organizationMemberRoleId); + if (!memberRole) { + throw new Error(`Could not find role '${assignedRole.organizationMemberRoleId}'.`); + } + + return { + resources: assignedRole.resources, + role: memberRole, + } satisfies OrganizationMembershipRoleAssignment; + }), + ] as const, + ), + ); + } + + findRoleAssignmentsForUserInOrganization = batchBy( + (args: { organizationId: string; userId: string }) => args.organizationId, + async args => { + const organizationId = args[0].organizationId; + const userIds = args.map(arg => arg.userId); + const result = await this.findRoleAssignmentsForUsersInOrganization(organizationId, userIds); + + return userIds.map(async userId => { + return result.get(userId) ?? []; + }); + }, + ); + + private async findOrganizationMembersById(organizationId: string, userIds: Array) { + const query = sql` + SELECT + "om"."user_id" AS "userId" + , "om"."role_id" AS "legacyRoleId" + , "om"."scopes" AS "legacyScopes" + FROM + "organization_member" AS "om" + WHERE + "om"."organization_id" = ${organizationId} + AND "om"."user_id" = ANY(${sql.array(userIds, 'uuid')}) + `; + + const result = await this.pool.any(query); + return result.map(row => RawOrganizationMembershipModel.parse(row)); + } + + private findOrganizationMemberById = batchBy( + (args: { organizationId: string; userId: string }) => args.organizationId, + async args => { + const organizationId = args[0].organizationId; + const userIds = args.map(arg => arg.userId); + const organizationMembers = await this.findOrganizationMembersById(organizationId, userIds); + const lookupMap = new Map(); + for (const member of organizationMembers) { + lookupMap.set(member.userId, member); + } + + return userIds.map(async userId => lookupMap.get(userId) ?? null); + }, + ); + + /** + * Batched loader function for a organization membership. + * + * Handles legacy scopes and role assignments and automatically transforms + * them into resource based role assignments. + */ + findOrganizationMembership = batchBy( + (args: { organization: Organization; userId: string }) => args.organization.id, + async args => { + const organization = args[0].organization; + const userIds = args.map(arg => arg.userId); + + this.logger.debug( + 'Find organization membership for users. (organizationId=%s, userIds=%o)', + organization.id, + userIds, + ); + + const organizationMembers = await this.findOrganizationMembersById(organization.id, userIds); + const mapping = new Map(); + + // Roles that are assigned using the legacy "single role" way + const pendingLegacyRoleLookups = new Set(); + const pendingLegacyRoleMembershipAssignments: Array<{ + legacyRoleId: string; + assignedRoles: OrganizationMembership['assignedRoles']; + }> = []; + + // Users whose role assignments need to be loaded as they are not using any legacy roles + const pendingRoleRoleAssignmentLookupUsersIds = new Set(); + + for (const record of organizationMembers) { + const organizationMembership: OrganizationMembership = { + organizationId: organization.id, + userId: record.userId, + isAdmin: organization.ownerId === record.userId, + assignedRoles: [], + legacyRoleId: record.legacyRoleId, + legacyScopes: record.legacyScopes, + }; + mapping.set(record.userId, organizationMembership); + + if (record.legacyRoleId) { + // legacy "single assigned role" + pendingLegacyRoleLookups.add(record.legacyRoleId); + pendingLegacyRoleMembershipAssignments.push({ + legacyRoleId: record.legacyRoleId, + assignedRoles: organizationMembership.assignedRoles, + }); + } else if (record.legacyScopes !== null) { + // legacy "scopes" on organization member -> migration wizard has not been used + + // In this case we translate the legacy scopes to a single permission group on the "organization" + // resource typ. Then assign the users organization to the group, so it has the same behavior as previously. + organizationMembership.assignedRoles.push({ + role: { + id: 'legacy-scope-role', + description: 'This role has been automatically generated from the assigned scopes.', + isLocked: true, + permissionGroups: [ + /** */ + transformOrganizationMemberLegacyScopesIntoPermissionGroup(record.legacyScopes), + ], + }, + resources: { + organization: [organization.id], + project: null, + target: null, + service: null, + }, + }); + } else { + // normal role assignment lookup + pendingRoleRoleAssignmentLookupUsersIds.add(organizationMembership); + } + } + + if (pendingLegacyRoleLookups.size) { + // This handles the legacy "single" role assignments + // We load the roles and then attach them to the already loaded membership role + const roleIds = Array.from(pendingLegacyRoleLookups); + + this.logger.debug('Lookup legacy role assignments. (roleIds=%o)', roleIds); + + const memberRolesById = await this.findMemberRolesByIds(roleIds); + + for (const record of pendingLegacyRoleMembershipAssignments) { + const membershipRole = memberRolesById.get(record.legacyRoleId); + if (!membershipRole) { + continue; + } + record.assignedRoles.push({ + resources: { + organization: [organization.id], + project: null, + target: null, + service: null, + }, + role: membershipRole, + }); + } + } + + if (pendingRoleRoleAssignmentLookupUsersIds.size) { + const usersIds = Array.from(pendingRoleRoleAssignmentLookupUsersIds).map( + membership => membership.userId, + ); + this.logger.debug( + 'Lookup role assignments within organization for users. (organizationId=%s, userIds=%o)', + organization.id, + usersIds, + ); + + const roleAssignments = await this.findRoleAssignmentsForUsersInOrganization( + organization.id, + usersIds, + ); + + for (const membership of pendingRoleRoleAssignmentLookupUsersIds) { + membership.assignedRoles.push(...(roleAssignments.get(membership.userId) ?? [])); + } + } + + return userIds.map(async userId => mapping.get(userId) ?? null); + }, + ); + + async createOrganizationMemberRole(args: { + name: string; + description: string; + permissionGroups: Array; + }) { + // TODO: implementation of the method + } + + async updateOrganizationMemberRole( + roleId: string, + args: { + name: string | null; + description: string | null; + permissionGroups: Array; + }, + ) { + const role = await this.findMemberRoleById(roleId); + + if (!role) { + return null; + } + + // TODO: assert if role is locked or not + + const query = sql` + UPDATE + "organization_member_roles" + SET + "name" = COALESCE(${args.name}, "name") + , "description" = COALESCE(${args.description}, "name") + ${/* Upon update we unset the legacy scopes */ sql``} + , "scopes" = NULL + , "permissions_groups" = ${JSON.stringify(PermissionGroupModel.parse(args.permissionGroups))} + WHERE + "id" = ${roleId} + `; + + await this.pool.query(query); + } + + async deleteOrganizationMemberRole(roleId: string) { + const role = await this.findMemberRoleById(roleId); + + // TODO: check if any user has this role assigned + + // TODO: delete role + } + + /** + * Assigns or updates the assignment of a member role on a user. + * + * It also handles legacy cleanup of moving the legacy assigned role form the "organization_member" table + * to the "organization_member_role_assignments" table. + */ + async upsertOrganizationMemberRoleAssignment( + organizationId: string, + userId: string, + // TODO: think about making this a upsert many role assignments method instead + roleId: string, + resourceAssignments: ResourceAssignmentGroup, + ) { + const membership = await this.findOrganizationMemberById({ + organizationId, + userId, + }); + + if (!membership) { + return null; + } + + // TODO: verify resourceAssignments + // TODO: verify role exists + + await this.pool.transaction(async transaction => { + // TODO: potential race condition; the organization membership lookup should run in the same transaction + + if (membership.legacyRoleId) { + // In case we have a legacy role attached to the membership we automatically move the assignment + // to the "organization_member_role_assignments" table. + await transaction.query(sql` + UPDATE + "organization_member" + SET + "role_id" = NULL + WHERE + "organization_id" = ${organizationId} + AND "user_id" = ${userId} + `); + + if (membership.legacyRoleId !== roleId) { + await transaction.query(sql` + INSERT INTO "organization_member_role_assignments" ( + "organization_id" + , "user_id" + , "organization_member_role_id" + , "resources" + ) VALUES ( + ${organizationId} + , ${userId} + , ${membership.legacyRoleId} + , ${JSON.stringify({ + organization: [organizationId], + })} + ) + `); + } + } + + await transaction.query(sql` + INSERT INTO "organization_member_role_assignments" ( + "organization_id" + , "user_id" + , "organization_member_role_id" + , "resources" + ) VALUES ( + ${organizationId} + , ${userId} + , ${roleId} + , ${JSON.stringify(ResourceAssignmentGroupModel.parse(resourceAssignments))} + ) + ON CONFLICT ("organization_id", "user_id", "organization_member_role_id") + DO UPDATE + SET "resources" = EXCLUDED."resources" + `); + }); + } + + /** + * Unassign a organization member role from an user + */ + async removeOrganizationMemberRoleAssignment( + organizationId: string, + userId: string, + // TODO: think about making this a remove many role assignments method instead + roleId: string, + ) { + const membership = await this.findOrganizationMemberById({ + organizationId, + userId, + }); + + if (!membership) { + return null; + } + + const roleAssignments = await this.findRoleAssignmentsForUserInOrganization({ + organizationId, + userId, + }); + + // A user can not have less than one role assigned. + if (roleAssignments.length <= 1) { + return null; + } + + // TODO: race condition; the role assignment lookup and delete need to happen in a transaction + // otherwise it is possible that a user without a role will exist. + + const query = sql` + DELETE + FROM + "organization_member_role_assignments" + WHERE + "organization_id" = ${organizationId} + AND "user_id" = ${userId} + AND "organization_member_role_id" = ${roleId} + `; + + await this.pool.query(query); + } +} diff --git a/packages/services/server/src/index.ts b/packages/services/server/src/index.ts index 8fa615b281..9382a4dc4a 100644 --- a/packages/services/server/src/index.ts +++ b/packages/services/server/src/index.ts @@ -15,6 +15,7 @@ import { createPubSub } from 'graphql-yoga'; import { z } from 'zod'; import formDataPlugin from '@fastify/formbody'; import { createRegistry, createTaskRunner, CryptoProvider, LogFn, Logger } from '@hive/api'; +import { OrganizationMembers } from '@hive/api/src/modules/auth/providers/organization-members'; import { HivePubSub } from '@hive/api/src/modules/shared/providers/pub-sub'; import { createRedisClient } from '@hive/api/src/modules/shared/providers/redis'; import { createArtifactRequestHandler } from '@hive/cdn-script/artifact-handler'; @@ -392,6 +393,7 @@ export async function main() { new SuperTokensUserAuthNStrategy({ logger: server.log, storage, + organizationMembers: new OrganizationMembers(storage.pool, server.log), }), new TargetAccessTokenStrategy({ logger: server.log, diff --git a/tsconfig.json b/tsconfig.json index 5b8a1f8313..4b49dccd6a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,6 +26,9 @@ "strict": true, "paths": { "@hive/api": ["./packages/services/api/src/index.ts"], + "@hive/api/src/modules/auth/providers/organization-members": [ + "./packages/services/api/src/modules/auth/providers/organization-members.ts" + ], "@hive/api/src/modules/schema/providers/artifact-storage-reader": [ "./packages/services/api/src/modules/schema/providers/artifact-storage-reader.ts" ],