diff --git a/src/database/initialization/ephemeral_user_group_association_to_json.sql b/src/database/initialization/ephemeral_user_group_association_to_json.sql new file mode 100644 index 00000000..5282aab1 --- /dev/null +++ b/src/database/initialization/ephemeral_user_group_association_to_json.sql @@ -0,0 +1,14 @@ +SELECT drop_function('ephemeral_user_group_association_to_json'); + +CREATE FUNCTION ephemeral_user_group_association_to_json( + ephemeral_user_group_association ephemeral_user_group_associations +) +RETURNS jsonb AS $$ +BEGIN + RETURN jsonb_build_object( + 'userKeycloakUserId', ephemeral_user_group_association.user_keycloak_user_id, + 'userGroupKeycloakOrganizationId', ephemeral_user_group_association.user_group_keycloak_organization_id, + 'createdAt', data_provider.created_at + ); +END; +$$ LANGUAGE plpgsql; diff --git a/src/database/migrations/0050-create-ephemeral-user-group-associations.sql b/src/database/migrations/0050-create-ephemeral-user-group-associations.sql new file mode 100644 index 00000000..04e7c916 --- /dev/null +++ b/src/database/migrations/0050-create-ephemeral-user-group-associations.sql @@ -0,0 +1,8 @@ +CREATE TABLE ephemeral_user_group_associations ( + user_keycloak_user_id uuid NOT NULL REFERENCES users ( + keycloak_user_id + ) ON DELETE CASCADE, + user_group_keycloak_organization_id uuid NOT NULL, + created_at timestamp with time zone NOT NULL DEFAULT now(), + PRIMARY KEY (user_keycloak_user_id, user_group_keycloak_organization_id) +); diff --git a/src/database/operations/ephemeralUserGroupAssociations/createEphemeralUserGroupAssociation.ts b/src/database/operations/ephemeralUserGroupAssociations/createEphemeralUserGroupAssociation.ts new file mode 100644 index 00000000..85178d9d --- /dev/null +++ b/src/database/operations/ephemeralUserGroupAssociations/createEphemeralUserGroupAssociation.ts @@ -0,0 +1,17 @@ +import { generateCreateOrUpdateItemOperation } from '../generators'; +import type { + EphemeralUserGroupAssociation, + InternallyWritableEphemeralUserGroupAssociation, +} from '../../../types'; + +const createEphemeralUserGroupAssociation = generateCreateOrUpdateItemOperation< + EphemeralUserGroupAssociation, + InternallyWritableEphemeralUserGroupAssociation, + [] +>( + 'ephemeralUserGroupAssociation.insertOne', + ['userKeycloakUserId', 'userGroupKeycloakOrganizationId'], + [], +); + +export { createEphemeralUserGroupAssociation }; diff --git a/src/database/operations/ephemeralUserGroupAssociations/index.ts b/src/database/operations/ephemeralUserGroupAssociations/index.ts new file mode 100644 index 00000000..24ee42a7 --- /dev/null +++ b/src/database/operations/ephemeralUserGroupAssociations/index.ts @@ -0,0 +1,2 @@ +export * from './createEphemeralUserGroupAssociation'; +export * from './removeEphemeralUserGroupAssociationsByUserKeycloakUserId'; diff --git a/src/database/operations/ephemeralUserGroupAssociations/removeEphemeralUserGroupAssociationsByUserKeycloakUserId.ts b/src/database/operations/ephemeralUserGroupAssociations/removeEphemeralUserGroupAssociationsByUserKeycloakUserId.ts new file mode 100644 index 00000000..a2e0a1cf --- /dev/null +++ b/src/database/operations/ephemeralUserGroupAssociations/removeEphemeralUserGroupAssociationsByUserKeycloakUserId.ts @@ -0,0 +1,10 @@ +import { generateRemoveOperation } from '../generators'; +import type { KeycloakId } from '../../../types'; + +const removeEphemeralUserGroupAssociationsByUserKeycloakUserId = + generateRemoveOperation<[userKeycloakUserId: KeycloakId]>( + 'ephemeralUserGroupAssociations.deleteByUserKeycloakUserId', + ['userKeycloakUserId'], + ); + +export { removeEphemeralUserGroupAssociationsByUserKeycloakUserId }; diff --git a/src/database/operations/index.ts b/src/database/operations/index.ts index 8383eaaa..5ead608d 100644 --- a/src/database/operations/index.ts +++ b/src/database/operations/index.ts @@ -7,6 +7,7 @@ export * from './bulkUploadTasks'; export * from './changemakerProposals'; export * from './changemakers'; export * from './dataProviders'; +export * from './ephemeralUserGroupAssociations'; export * from './funders'; export * from './generic'; export * from './opportunities'; diff --git a/src/database/queries/ephemeralUserGroupAssociations/deleteByUserKeycloakUserId.sql b/src/database/queries/ephemeralUserGroupAssociations/deleteByUserKeycloakUserId.sql new file mode 100644 index 00000000..80daf19a --- /dev/null +++ b/src/database/queries/ephemeralUserGroupAssociations/deleteByUserKeycloakUserId.sql @@ -0,0 +1,3 @@ +DELETE FROM ephemeral_user_group_associations +WHERE user_keycloak_user_id = :userKeycloakUserId +RETURNING *; diff --git a/src/database/queries/ephemeralUserGroupAssociations/insertOne.sql b/src/database/queries/ephemeralUserGroupAssociations/insertOne.sql new file mode 100644 index 00000000..a58b96c5 --- /dev/null +++ b/src/database/queries/ephemeralUserGroupAssociations/insertOne.sql @@ -0,0 +1,15 @@ +INSERT INTO ephemeral_user_group_associations ( + user_keycloak_user_id, + user_group_keycloak_organization_id +) VALUES ( + :userKeycloakUserId, + :userGroupKeycloakOrganizationId +) +ON CONFLICT ( + user_keycloak_user_id, permission, user_group_keycloak_organization_id +) DO UPDATE +SET user_keycloak_user_id = user_keycloak_user_id +RETURNING + ephemeral_user_group_association_to_json( + ephemeral_user_group_associations + ) AS object; diff --git a/src/middleware/addUserContext.ts b/src/middleware/addUserContext.ts index c199bbcf..f4909a79 100644 --- a/src/middleware/addUserContext.ts +++ b/src/middleware/addUserContext.ts @@ -1,6 +1,13 @@ -import { db, createOrUpdateUser } from '../database'; +import { + db, + createOrUpdateUser, + loadUserByKeycloakUserId, + createEphemeralUserGroupAssociation, + removeEphemeralUserGroupAssociationsByUserKeycloakUserId, +} from '../database'; import { getAuthSubFromRequest, + getKeycloakOrganizationIdsFromRequest, isKeycloakId, keycloakIdToString, stringToKeycloakId, @@ -16,6 +23,7 @@ const addUserContext = ( next: NextFunction, ): void => { const keycloakUserId = getAuthSubFromRequest(req); + const keycloakOrganizationIds = getKeycloakOrganizationIdsFromRequest(req); const systemUser = getSystemUser(); if ( keycloakUserId === undefined || @@ -35,15 +43,41 @@ const addUserContext = ( return; } - createOrUpdateUser(db, null, { - keycloakUserId: stringToKeycloakId(keycloakUserId), - }) - .then((user) => { - (req as AuthenticatedRequest).user = user; - next(); + Promise.all([ + createOrUpdateUser(db, null, { + keycloakUserId: stringToKeycloakId(keycloakUserId), + }), + removeEphemeralUserGroupAssociationsByUserKeycloakUserId( + db, + null, + keycloakUserId, + ), + ]) + .then(() => { + const createEphemeralUserGroupAssociationPromises = + keycloakOrganizationIds.map((keycloakOrganizationId) => + createEphemeralUserGroupAssociation(db, null, { + userKeycloakUserId: keycloakUserId, + userGroupKeycloakOrganizationId: keycloakOrganizationId, + }), + ); + Promise.all(createEphemeralUserGroupAssociationPromises) + .then(() => { + loadUserByKeycloakUserId(db, null, keycloakUserId) + .then((user) => { + (req as AuthenticatedRequest).user = user; + next(); + }) + .catch((err) => { + next(err); + }); + }) + .catch((err) => { + next(err); + }); }) - .catch((error: unknown) => { - next(error); + .catch((err) => { + next(err); }); }; diff --git a/src/types/EphemeralUserGroupAssociation.ts b/src/types/EphemeralUserGroupAssociation.ts new file mode 100644 index 00000000..aa6ed4c3 --- /dev/null +++ b/src/types/EphemeralUserGroupAssociation.ts @@ -0,0 +1,24 @@ +import { KeycloakId } from './KeycloakId'; +import { Writable } from './Writable'; + +interface EphemeralUserGroupAssociation { + readonly userKeycloakUserId: KeycloakId; + readonly userGroupKeycloakOrganizationId: KeycloakId; + readonly createdAt: string; +} + +type WritableEphemeralUserGroupAssociation = + Writable; + +type InternallyWritableEphemeralUserGroupAssociation = + WritableEphemeralUserGroupAssociation & + Pick< + EphemeralUserGroupAssociation, + 'userKeycloakUserId' | 'userGroupKeycloakOrganizationId' + >; + +export { + EphemeralUserGroupAssociation, + InternallyWritableEphemeralUserGroupAssociation, + WritableEphemeralUserGroupAssociation, +}; diff --git a/src/types/express/AuthenticatedRequest.ts b/src/types/express/AuthenticatedRequest.ts index f387d0e2..b5467d1e 100644 --- a/src/types/express/AuthenticatedRequest.ts +++ b/src/types/express/AuthenticatedRequest.ts @@ -1,5 +1,6 @@ import { Request as JwtRequest } from 'express-jwt'; import { ajv } from '../../ajv'; +import { KeycloakId, keycloakIdSchema } from '../KeycloakId'; import type { JSONSchemaType } from 'ajv'; import type { Request } from 'express'; import type { AuthContext } from '../AuthContext'; @@ -20,6 +21,12 @@ interface ObjectWithAuthWithRealmAccessRoles { }; } +interface ObjectWithAuthWithOrganizations { + auth: { + organizations: Record; + }; +} + const objectWithAuthWithSubSchema: JSONSchemaType = { type: 'object', properties: { @@ -60,18 +67,54 @@ const objectWithAuthWithRealmAccessRolesSchema: JSONSchemaType = + { + type: 'object', + properties: { + auth: { + type: 'object', + properties: { + organizations: { + type: 'object', + additionalProperties: { + type: 'object', + properties: { + id: keycloakIdSchema, + }, + required: ['id'], + }, + required: [], + }, + }, + required: ['organizations'], + }, + }, + required: ['auth'], + }; + const hasAuthWithSub = ajv.compile(objectWithAuthWithSubSchema); const hasAuthWithRealmAccessRoles = ajv.compile( objectWithAuthWithRealmAccessRolesSchema, ); +const isObjectWithAuthWithOrganizations = ajv.compile( + objectWithAuthWithOrganizationsSchema, +); + const getAuthSubFromRequest = (req: Request): string | undefined => hasAuthWithSub(req) ? req.auth.sub : undefined; const getRealmAccessRolesFromRequest = (req: Request): string[] => hasAuthWithRealmAccessRoles(req) ? req.auth.realm_access.roles : []; +const getKeycloakOrganizationIdsFromRequest = (req: Request): KeycloakId[] => + isObjectWithAuthWithOrganizations(req) + ? Object.values(req.auth.organizations).map( + (organization) => organization.id, + ) + : []; + const hasMeaningfulAuthSub = (req: Request): boolean => { const authSub = getAuthSubFromRequest(req); return authSub !== undefined && authSub !== ''; @@ -81,5 +124,6 @@ export { AuthenticatedRequest, getAuthSubFromRequest, getRealmAccessRolesFromRequest, + getKeycloakOrganizationIdsFromRequest, hasMeaningfulAuthSub, }; diff --git a/src/types/index.ts b/src/types/index.ts index 84140487..1daa6564 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -11,6 +11,7 @@ export * from './ChangemakerProposal'; export * from './CheckResult'; export * from './CopyBaseFieldsJobPayload'; export * from './DataProvider'; +export * from './EphemeralUserGroupAssociation'; export * from './express/AuthenticatedRequest'; export * from './Funder'; export * from './Id';