diff --git a/README.OGCIO.md b/README.OGCIO.md new file mode 100644 index 00000000000..80bb736549f --- /dev/null +++ b/README.OGCIO.md @@ -0,0 +1,10 @@ +# LogTo per OGCIO + +## Get started + +If you want to run it locally, you just have to run +``` +make build run +``` + +And, once the process ended, you're ready to open `http://localhost:3302` on your browser to navigate on your LogTo instance! \ No newline at end of file diff --git a/docker-compose-local.yml b/docker-compose-local.yml new file mode 100644 index 00000000000..84cb5948b36 --- /dev/null +++ b/docker-compose-local.yml @@ -0,0 +1,40 @@ +# This compose file is for demonstration only, do not use in prod. +version: "3.9" +services: + app: + depends_on: + postgres: + condition: service_healthy + image: local-logto:latest + entrypoint: + [ + "sh", + "-c", + "npm run cli db seed -- --swe && npm run cli db ogcio && npm start" + ] + ports: + - 3301:3301 + - 3302:3302 + environment: + - TRUST_PROXY_HEADER=1 + - DB_URL=postgres://postgres:p0stgr3s@postgres:5433/logto + # Mandatory for GitPod to map host env to the container, thus GitPod can dynamically configure the public URL of Logto; + # Or, you can leverage it for local testing. + - ENDPOINT + - ADMIN_ENDPOINT + - PORT=3301 + - ADMIN_PORT=3302 + postgres: + image: postgres:14-alpine + user: postgres + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: p0stgr3s + PGPORT: 5433 + healthcheck: + test: [ "CMD-SHELL", "pg_isready" ] + interval: 10s + timeout: 5s + retries: 5 + ports: + - 5433:5433 diff --git a/makefile b/makefile new file mode 100644 index 00000000000..b61c2be76f6 --- /dev/null +++ b/makefile @@ -0,0 +1,7 @@ +TAG = local-logto:latest + +build: + docker build -t ${TAG} . +run: + docker-compose -f docker-compose-local.yml up --detach + diff --git a/packages/cli/src/commands/database/index.ts b/packages/cli/src/commands/database/index.ts index e0479ee7bb6..05a95871e97 100644 --- a/packages/cli/src/commands/database/index.ts +++ b/packages/cli/src/commands/database/index.ts @@ -3,6 +3,7 @@ import type { CommandModule } from 'yargs'; import alteration from './alteration/index.js'; import config from './config.js'; +import ogcio from './ogcio/index.js'; import seed from './seed/index.js'; import system from './system.js'; @@ -10,7 +11,13 @@ const database: CommandModule = { command: ['database', 'db'], describe: 'Commands for Logto database', builder: (yargs) => - yargs.command(config).command(seed).command(alteration).command(system).demandCommand(1), + yargs + .command(config) + .command(seed) + .command(alteration) + .command(system) + .command(ogcio) + .demandCommand(1), handler: noop, }; diff --git a/packages/cli/src/commands/database/ogcio/applications.ts b/packages/cli/src/commands/database/ogcio/applications.ts new file mode 100644 index 00000000000..59446af2df9 --- /dev/null +++ b/packages/cli/src/commands/database/ogcio/applications.ts @@ -0,0 +1,78 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ + +/* eslint-disable @silverhand/fp/no-mutating-methods */ +/* eslint-disable @silverhand/fp/no-mutation */ +import { ApplicationType } from '@logto/schemas'; +import { generateStandardSecret } from '@logto/shared'; +import { sql, type DatabaseTransactionConnection } from 'slonik'; + +import { type OgcioParams } from './index.js'; +import { createItem } from './queries.js'; + +type ApplicationParams = Omit; + +const createApplication = async ( + transaction: DatabaseTransactionConnection, + tenantId: string, + appToSeed: SeedingApplication +) => + createItem({ + transaction, + tenantId, + toInsert: appToSeed, + toLogFieldName: 'name', + itemTypeName: 'Application', + whereClauses: [sql`name = ${appToSeed.name}`], + tableName: 'applications', + }); + +const setApplicationId = async ( + element: SeedingApplication, + transaction: DatabaseTransactionConnection, + tenantId: string +) => { + element = await createApplication(transaction, tenantId, element); +}; + +const createApplications = async ( + transaction: DatabaseTransactionConnection, + tenantId: string, + cliParams: ApplicationParams +): Promise> => { + const appsToCreate = { payments: fillPaymentsApplication(cliParams) }; + const queries: Array> = []; + for (const element of Object.values(appsToCreate)) { + queries.push(setApplicationId(element, transaction, tenantId)); + } + + await Promise.all(queries); + + return appsToCreate; +}; + +type SeedingApplication = { + name: string; + secret: string; + description: string; + type: string; + oidc_client_metadata: string; + custom_client_metadata: string; + protected_app_metadata?: string; + is_third_party?: boolean; +}; + +const fillPaymentsApplication = (cliParams: ApplicationParams): SeedingApplication => ({ + name: 'Life Events Payments App', + secret: generateStandardSecret(), + description: 'Payments App of Life Events', + type: ApplicationType.Traditional, + oidc_client_metadata: `{"redirectUris": ["${cliParams.appRedirectUri}"], "postLogoutRedirectUris": ["${cliParams.appLogoutRedirectUri}"]}`, + custom_client_metadata: + '{"idTokenTtl": 3600, "corsAllowedOrigins": [], "rotateRefreshToken": true, "refreshTokenTtlInDays": 14, "alwaysIssueRefreshToken": false}', +}); + +export const seedApplications = async ( + transaction: DatabaseTransactionConnection, + tenantId: string, + cliParams: ApplicationParams +) => createApplications(transaction, tenantId, cliParams); diff --git a/packages/cli/src/commands/database/ogcio/index.ts b/packages/cli/src/commands/database/ogcio/index.ts new file mode 100644 index 00000000000..e1050f05456 --- /dev/null +++ b/packages/cli/src/commands/database/ogcio/index.ts @@ -0,0 +1,87 @@ +import type { CommandModule } from 'yargs'; + +import { createPoolAndDatabaseIfNeeded } from '../../../database.js'; +import { consoleLog } from '../../../utils.js'; + +import { seedOgcio } from './ogcio.js'; + +export type OgcioParams = { + apiIndicator: string; + appRedirectUri: string; + appLogoutRedirectUri: string; +}; + +type UnknownOgcioParams = { + apiIndicator?: unknown; + appRedirectUri?: unknown; + appLogoutRedirectUri?: unknown; +}; + +const isValidUrl = (inputParam: string): boolean => { + try { + const _url = new URL(inputParam); + return true; + } catch { + return false; + } +}; + +const isValidParam = (inputParam?: unknown): inputParam is string => { + return ( + inputParam !== undefined && + typeof inputParam === 'string' && + inputParam.length > 0 && + isValidUrl(inputParam) + ); +}; + +const checkParams = (inputParams: UnknownOgcioParams): Required => { + const { apiIndicator, appRedirectUri, appLogoutRedirectUri } = inputParams; + if (!isValidParam(apiIndicator)) { + throw new Error('apiIndicator must be set'); + } + if (!isValidParam(appRedirectUri)) { + throw new Error('appRedirectUri must be set'); + } + if (!isValidParam(appLogoutRedirectUri)) { + throw new Error('appLogoutRedirectUri must be set'); + } + + return { apiIndicator, appRedirectUri, appLogoutRedirectUri }; +}; + +const ogcio: CommandModule> = { + command: 'ogcio', + describe: 'Seed OGCIO data', + builder: (yargs) => + yargs + .option('api-indicator', { + describe: 'The root url for the seeded API resource', + type: 'string', + default: 'http://localhost:8001', + }) + .option('app-redirect-uri', { + describe: 'The callback url to set for the seeded application', + type: 'string', + default: 'http://localhost:3001/callback', + }) + .option('app-logout-redirect-uri', { + describe: 'The callback url to set for the seeded application', + type: 'string', + default: 'http://localhost:3001', + }), + handler: async ({ apiIndicator, appRedirectUri, appLogoutRedirectUri }) => { + const params = checkParams({ apiIndicator, appRedirectUri, appLogoutRedirectUri }); + const pool = await createPoolAndDatabaseIfNeeded(); + try { + await seedOgcio(pool, params); + } catch (error: unknown) { + consoleLog.error(error); + throw error; + } finally { + await pool.end(); + } + }, +}; + +export default ogcio; diff --git a/packages/cli/src/commands/database/ogcio/ogcio.ts b/packages/cli/src/commands/database/ogcio/ogcio.ts new file mode 100644 index 00000000000..11b2edbae7a --- /dev/null +++ b/packages/cli/src/commands/database/ogcio/ogcio.ts @@ -0,0 +1,40 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ +/* eslint-disable @silverhand/fp/no-mutation */ +/* eslint-disable @silverhand/fp/no-let */ + +import { defaultTenantId } from '@logto/schemas'; +import type { CommonQueryMethods, DatabaseTransactionConnection } from 'slonik'; + +import { seedApplications } from './applications.js'; +import { type OgcioParams } from './index.js'; +import { seedOrganizationRbacData } from './organizations-rbac.js'; +import { createOrganization } from './organizations.js'; +import { seedResourceRbacData } from './resources-rbac.js'; +import { seedResources } from './resources.js'; + +let inputOgcioParams: OgcioParams = { + apiIndicator: '', + appLogoutRedirectUri: '', + appRedirectUri: '', +}; + +const transactionMethod = async (transaction: DatabaseTransactionConnection) => { + const organizationId = await createOrganization(transaction, defaultTenantId); + const organizationRbac = await seedOrganizationRbacData(transaction, defaultTenantId); + const applications = await seedApplications(transaction, defaultTenantId, { + appRedirectUri: inputOgcioParams.appRedirectUri, + appLogoutRedirectUri: inputOgcioParams.appLogoutRedirectUri, + }); + const resources = await seedResources( + transaction, + defaultTenantId, + inputOgcioParams.apiIndicator + ); + const resourcesRbac = await seedResourceRbacData(transaction, defaultTenantId, resources); +}; + +export const seedOgcio = async (connection: CommonQueryMethods, params: OgcioParams) => { + inputOgcioParams = params; + + await connection.transaction(transactionMethod); +}; diff --git a/packages/cli/src/commands/database/ogcio/organizations-rbac.ts b/packages/cli/src/commands/database/ogcio/organizations-rbac.ts new file mode 100644 index 00000000000..60829a8da04 --- /dev/null +++ b/packages/cli/src/commands/database/ogcio/organizations-rbac.ts @@ -0,0 +1,237 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable @silverhand/fp/no-mutating-methods */ +/* eslint-disable @silverhand/fp/no-mutation */ +import { OrganizationScopes, OrganizationRoles } from '@logto/schemas'; +import { sql, type DatabaseTransactionConnection } from 'slonik'; + +import { createItem, createItemWithoutId } from './queries.js'; + +const createOrganizationScope = async ( + transaction: DatabaseTransactionConnection, + tenantId: string, + scopeToSeed: SeedingScope +) => + createItem({ + transaction, + tenantId, + toInsert: scopeToSeed, + toLogFieldName: 'name', + itemTypeName: 'Organization Scope', + whereClauses: [sql`name = ${scopeToSeed.name}`], + tableName: OrganizationScopes.table, + }); + +type SeedingScope = { + name: string; + id: string | undefined; + description: string; +}; + +type ScopesLists = { + scopesList: SeedingScope[]; + scopesByResource: Record; + scopesByAction: Record; +}; + +const fillScopes = () => { + const resources = ['payments', 'messages', 'events']; + const actions = ['read', 'write', 'create', 'delete']; + const scopesList: SeedingScope[] = []; + const scopesByResource: Record = {}; + const scopesByAction: Record = {}; + + for (const resource of resources) { + scopesByResource[resource] = []; + for (const action of actions) { + const scope: SeedingScope = { + name: `${resource}:${action}`, + description: `${action} ${resource}`, + id: undefined, + }; + scopesList.push(scope); + if (scopesByResource[resource] === undefined) { + scopesByResource[resource] = [scope]; + } + scopesByResource[resource]!.push(scope); + if (scopesByAction[action] === undefined) { + scopesByAction[action] = []; + } + scopesByAction[action]!.push(scope); + } + } + const superScope: SeedingScope = { + name: 'ogcio:admin', + description: 'OGCIO Admin', + id: undefined, + }; + + scopesList.push(superScope); + scopesByResource.ogcio = [superScope]; + scopesByAction.admin = [superScope]; + + return { + scopesList, + scopesByResource, + scopesByAction, + }; +}; + +const setScopeId = async ( + element: SeedingScope, + transaction: DatabaseTransactionConnection, + tenantId: string +) => { + element = await createOrganizationScope(transaction, tenantId, element); +}; + +const createScopes = async ( + transaction: DatabaseTransactionConnection, + tenantId: string +): Promise => { + const scopesToCreate = fillScopes(); + const queries: Array> = []; + for (const element of scopesToCreate.scopesList) { + queries.push(setScopeId(element, transaction, tenantId)); + } + + await Promise.all(queries); + + return scopesToCreate; +}; + +type SeedingRole = { + name: string; + scopes: SeedingScope[]; + description: string; + id: string | undefined; +}; +const fillRoles = (scopesLists: ScopesLists) => { + const employee: SeedingRole = { + name: 'OGCIO Employee', + scopes: scopesLists.scopesByAction.read!, + description: 'Only read permissions', + id: undefined, + }; + const manager: SeedingRole = { + name: 'OGCIO Manager', + scopes: scopesLists.scopesByAction.read!, + description: 'Read write delete permissions', + id: undefined, + }; + // Don't ask me why, linter don't like spread operator, so I add to write multiple lines + manager.scopes = manager.scopes.concat(scopesLists.scopesByAction.write!); + manager.scopes = manager.scopes.concat(scopesLists.scopesByAction.delete!); + const admin: SeedingRole = { + name: 'OGCIO Admin', + scopes: scopesLists.scopesByAction.admin!, + description: 'Read write delete and admin permissions', + id: undefined, + }; + admin.scopes = admin.scopes.concat(scopesLists.scopesByAction.write!); + admin.scopes = admin.scopes.concat(scopesLists.scopesByAction.delete!); + admin.scopes = admin.scopes.concat(scopesLists.scopesByAction.read!); + + return [admin, manager, employee]; +}; + +const createRole = async ( + transaction: DatabaseTransactionConnection, + tenantId: string, + roleToSeed: SeedingRole +) => { + const created = await createItem({ + transaction, + tableName: OrganizationRoles.table, + tenantId, + toLogFieldName: 'name', + whereClauses: [sql`name = ${roleToSeed.name}`], + toInsert: { name: roleToSeed.name, description: roleToSeed.description }, + itemTypeName: 'Organization Role', + }); + + roleToSeed.id = created.id; + + return roleToSeed; +}; + +type SeedingRelation = { organization_role_id: string; organization_scope_id: string }; + +const createRoleScopeRelation = async ( + transaction: DatabaseTransactionConnection, + tenantId: string, + relation: SeedingRelation +) => + createItemWithoutId({ + transaction, + tableName: 'organization_role_scope_relations', + tenantId, + toLogFieldName: 'organization_role_id', + whereClauses: [ + sql`organization_role_id = ${relation.organization_role_id}`, + sql`organization_scope_id = ${relation.organization_scope_id}`, + ], + toInsert: relation, + itemTypeName: 'Organization Scope-Role relation', + columnToGet: 'organization_role_id', + }); + +const createRelations = async ( + transaction: DatabaseTransactionConnection, + tenantId: string, + roles: Record +) => { + const queries: Array> = []; + for (const role of Object.values(roles)) { + for (const scope of role.scopes) { + queries.push( + createRoleScopeRelation(transaction, tenantId, { + organization_role_id: role.id!, + organization_scope_id: scope.id!, + }) + ); + } + } + return Promise.all(queries); +}; + +const addRole = async ( + transaction: DatabaseTransactionConnection, + tenantId: string, + role: SeedingRole, + toFill: Record +) => { + toFill[role.name] = await createRole(transaction, tenantId, role); +}; + +const createRoles = async ( + transaction: DatabaseTransactionConnection, + tenantId: string, + scopesLists: ScopesLists +): Promise> => { + const rolesToCreate = fillRoles(scopesLists); + const queries: Array> = []; + const outputList: Record = {}; + for (const role of rolesToCreate) { + queries.push(addRole(transaction, tenantId, role, outputList)); + } + + await Promise.all(queries); + + return outputList; +}; + +export const seedOrganizationRbacData = async ( + transaction: DatabaseTransactionConnection, + tenantId: string +): Promise<{ + scopes: ScopesLists; + roles: Record; + relations: SeedingRelation[]; +}> => { + const createdScopes = await createScopes(transaction, tenantId); + const createdRoles = await createRoles(transaction, tenantId, createdScopes); + const relations = await createRelations(transaction, tenantId, createdRoles); + + return { scopes: createdScopes, roles: createdRoles, relations }; +}; diff --git a/packages/cli/src/commands/database/ogcio/organizations.ts b/packages/cli/src/commands/database/ogcio/organizations.ts new file mode 100644 index 00000000000..b06fea5e686 --- /dev/null +++ b/packages/cli/src/commands/database/ogcio/organizations.ts @@ -0,0 +1,19 @@ +import { type DatabaseTransactionConnection, sql } from 'slonik'; + +import { createItem } from './queries.js'; + +export const createOrganization = async ( + transaction: DatabaseTransactionConnection, + tenantId: string +) => { + const toInsert = { name: 'OGCIO Seeded Org', description: 'Organization created through seeder' }; + return createItem({ + transaction, + tenantId, + toInsert, + whereClauses: [sql`name = ${toInsert.name}`], + toLogFieldName: 'name', + tableName: 'organizations', + itemTypeName: 'Organization', + }); +}; diff --git a/packages/cli/src/commands/database/ogcio/queries.ts b/packages/cli/src/commands/database/ogcio/queries.ts new file mode 100644 index 00000000000..76dd668506c --- /dev/null +++ b/packages/cli/src/commands/database/ogcio/queries.ts @@ -0,0 +1,148 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ +/* eslint-disable unicorn/prefer-string-replace-all */ +/* eslint-disable @silverhand/fp/no-mutating-methods */ +/* eslint-disable @silverhand/fp/no-mutation */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { generateStandardId } from '@logto/shared'; +import { + type DatabaseTransactionConnection, + type QueryResult, + sql, + type ValueExpression, +} from 'slonik'; + +import { insertInto } from '../../../database.js'; +import { consoleLog } from '../../../utils.js'; + +const snakeToCamel = (input: string): string => + input.replace(/(?!^)_(.)/g, (_, char: string) => char.toUpperCase()); + +export const getColumnValueByQueryResult = >( + result: QueryResult, + columnToGet: string +): string | undefined => { + const camelColumn = snakeToCamel(columnToGet); + if (result.rows[0] === undefined || result.rows[0][camelColumn] === undefined) { + return undefined; + } + + return result.rows[0][camelColumn]; +}; + +export const getIdByQueryResult = ( + result: QueryResult +): string | undefined => getColumnValueByQueryResult(result, 'id'); + +const getInsertedColumnValue = async (params: { + transaction: DatabaseTransactionConnection; + tenantId: string | undefined; + whereClauses: ValueExpression[]; + tableName: string; + columnToGet: string; +}): Promise => { + const { whereClauses, tenantId, tableName, columnToGet, transaction } = params; + const cloneWhere = [...whereClauses]; + if (tenantId !== undefined) { + cloneWhere.push(sql`tenant_id = ${tenantId}`); + } + + const scope = await transaction.query>(sql` + select ${sql.identifier([columnToGet])} from ${sql.identifier([tableName])} + where ${sql.join(cloneWhere, sql` AND `)} + limit 1 + `); + + return getColumnValueByQueryResult(scope, columnToGet); +}; + +export const getInsertedId = async ( + transaction: DatabaseTransactionConnection, + tenantId: string | undefined, + whereClauses: ValueExpression[], + tableName: string +): Promise => + getInsertedColumnValue({ transaction, tenantId, whereClauses, tableName, columnToGet: 'id' }); + +export const createItem = async < + T extends { id?: string } & Record, +>(params: { + transaction: DatabaseTransactionConnection; + tenantId?: string; + toInsert: T; + toLogFieldName: string; + itemTypeName: string; + whereClauses: ValueExpression[]; + tableName: string; +}): Promise & { id: string }> => { + const prefixConsoleEntry = `Creating ${params.itemTypeName}. TenantId: ${ + params.tenantId ?? 'NOT SET' + }. Name: ${params.toInsert[params.toLogFieldName]!.toString()}`; + consoleLog.info(prefixConsoleEntry); + const scopeIdBefore = await getInsertedId( + params.transaction, + params.tenantId, + params.whereClauses, + params.tableName + ); + if (scopeIdBefore !== undefined) { + consoleLog.info(`${prefixConsoleEntry}. Already exists.`); + params.toInsert.id = scopeIdBefore; + return { ...params.toInsert, id: scopeIdBefore }; + } + + const toInsertData = { + ...params.toInsert, + id: generateStandardId(), + tenant_id: params.tenantId, + }; + + await params.transaction.query(insertInto(toInsertData, params.tableName)); + params.toInsert.id = await getInsertedId( + params.transaction, + params.tenantId, + params.whereClauses, + params.tableName + ); + if (params.toInsert.id !== undefined) { + consoleLog.info(`${prefixConsoleEntry}. Created, Id ${params.toInsert.id}`); + return { ...params.toInsert, id: params.toInsert.id }; + } + + throw new Error(`${prefixConsoleEntry}. Failure inserting it!`); +}; + +export const createItemWithoutId = async < + T extends Record, +>(params: { + transaction: DatabaseTransactionConnection; + tenantId: string | undefined; + toInsert: T; + toLogFieldName: string; + itemTypeName: string; + whereClauses: ValueExpression[]; + tableName: string; + columnToGet: string; +}): Promise => { + const prefixConsoleEntry = `Creating ${params.itemTypeName}. TenantId: ${ + params.tenantId ?? 'NOT SET' + }. Name: ${params.toInsert[params.toLogFieldName]!.toString()}`; + consoleLog.info(prefixConsoleEntry); + const scopeIdBefore = await getInsertedColumnValue(params); + if (scopeIdBefore !== undefined) { + consoleLog.info(`${prefixConsoleEntry}. Already exists.`); + return { ...params.toInsert, [params.columnToGet]: scopeIdBefore }; + } + + const toInsertData = { + tenant_id: params.tenantId, + ...params.toInsert, + }; + await params.transaction.query(insertInto(toInsertData, params.tableName)); + const outputValue = await getInsertedColumnValue(params); + if (outputValue !== undefined) { + consoleLog.info(`${prefixConsoleEntry}. Created, ${params.columnToGet} ${outputValue}`); + return { ...params.toInsert, [params.columnToGet]: outputValue }; + } + + throw new Error(`${prefixConsoleEntry}. Failure inserting it!`); +}; diff --git a/packages/cli/src/commands/database/ogcio/resources-rbac.ts b/packages/cli/src/commands/database/ogcio/resources-rbac.ts new file mode 100644 index 00000000000..f9c2daf809a --- /dev/null +++ b/packages/cli/src/commands/database/ogcio/resources-rbac.ts @@ -0,0 +1,255 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable @silverhand/fp/no-mutating-methods */ +/* eslint-disable @silverhand/fp/no-mutation */ +import { sql, type DatabaseTransactionConnection } from 'slonik'; + +import { createItem } from './queries.js'; + +const createResourceScope = async ( + transaction: DatabaseTransactionConnection, + tenantId: string, + scopeToSeed: SeedingScope +) => + createItem({ + transaction, + tenantId, + toInsert: scopeToSeed, + toLogFieldName: 'name', + itemTypeName: 'Resource Scope', + whereClauses: [sql`name = ${scopeToSeed.name}`], + tableName: 'scopes', + }); + +type SeedingScope = { + name: string; + resource_id: string; + id: string | undefined; + description: string; +}; + +type ScopesLists = { + scopesList: SeedingScope[]; + scopesByResource: Record; + scopesByAction: Record; +}; + +const fillScopes = (resourceId: string) => { + const resources = ['payments', 'payments-requests']; + const actions = ['read', 'write', 'create', 'delete']; + const scopesList: SeedingScope[] = []; + const scopesByResource: Record = {}; + const scopesByAction: Record = {}; + + for (const resource of resources) { + scopesByResource[resource] = []; + for (const action of actions) { + const scope: SeedingScope = { + name: `${resource}:${action}`, + description: `${action} ${resource}`, + id: undefined, + resource_id: resourceId, + }; + scopesList.push(scope); + if (scopesByResource[resource] === undefined) { + scopesByResource[resource] = [scope]; + } + scopesByResource[resource]!.push(scope); + if (scopesByAction[action] === undefined) { + scopesByAction[action] = []; + } + scopesByAction[action]!.push(scope); + } + } + const superScope: SeedingScope = { + name: 'ogcio:admin', + description: 'OGCIO Admin', + id: undefined, + resource_id: resourceId, + }; + + scopesList.push(superScope); + scopesByResource.ogcio = [superScope]; + scopesByAction.admin = [superScope]; + + return { + scopesList, + scopesByResource, + scopesByAction, + }; +}; + +const setScopeId = async ( + element: SeedingScope, + transaction: DatabaseTransactionConnection, + tenantId: string +) => { + element = await createResourceScope(transaction, tenantId, element); +}; + +const createScopesPerResource = async ( + transaction: DatabaseTransactionConnection, + tenantId: string, + resourceId: string +): Promise<{ + scopesList: SeedingScope[]; + scopesByResource: Record; + scopesByAction: Record; +}> => { + const scopesToCreate = fillScopes(resourceId); + const queries: Array> = []; + for (const element of scopesToCreate.scopesList) { + queries.push(setScopeId(element, transaction, tenantId)); + } + + await Promise.all(queries); + + return scopesToCreate; +}; + +type SeedingRole = { + name: string; + scopes: SeedingScope[]; + description: string; + id: string | undefined; +}; +const fillRoles = (scopesLists: ScopesLists) => { + const employee: SeedingRole = { + name: 'Payments Employee', + scopes: scopesLists.scopesByAction.read!, + description: 'Only read permissions', + id: undefined, + }; + const manager: SeedingRole = { + name: 'Payments Manager', + scopes: scopesLists.scopesByAction.read!, + description: 'Read write delete permissions', + id: undefined, + }; + // Don't ask me why, linter don't like spread operator, so I add to write multiple lines + manager.scopes = manager.scopes.concat(scopesLists.scopesByAction.write!); + manager.scopes = manager.scopes.concat(scopesLists.scopesByAction.delete!); + const admin: SeedingRole = { + name: 'Payments Admin', + scopes: scopesLists.scopesByAction.admin!, + description: 'Read write delete and admin permissions', + id: undefined, + }; + admin.scopes = admin.scopes.concat(scopesLists.scopesByAction.write!); + admin.scopes = admin.scopes.concat(scopesLists.scopesByAction.delete!); + admin.scopes = admin.scopes.concat(scopesLists.scopesByAction.read!); + + return [admin, manager, employee]; +}; + +const createRole = async ( + transaction: DatabaseTransactionConnection, + tenantId: string, + roleToSeed: SeedingRole +) => { + const created = await createItem({ + transaction, + tableName: 'roles', + tenantId, + toLogFieldName: 'name', + whereClauses: [sql`name = ${roleToSeed.name}`], + toInsert: { name: roleToSeed.name, description: roleToSeed.description }, + itemTypeName: 'Resource Role', + }); + + roleToSeed.id = created.id; + + return roleToSeed; +}; + +type SeedingRelation = { role_id: string; scope_id: string; id?: string }; + +const createRoleScopeRelation = async ( + transaction: DatabaseTransactionConnection, + tenantId: string, + relation: SeedingRelation +) => + createItem({ + transaction, + tableName: 'roles_scopes', + tenantId, + toLogFieldName: 'role_id', + whereClauses: [sql`role_id = ${relation.role_id}`, sql`scope_id = ${relation.scope_id}`], + toInsert: relation, + itemTypeName: 'Scope-Role relation', + }); + +const createRelations = async ( + transaction: DatabaseTransactionConnection, + tenantId: string, + roles: Record +) => { + const queries: Array> = []; + for (const role of Object.values(roles)) { + for (const scope of role.scopes) { + queries.push( + createRoleScopeRelation(transaction, tenantId, { + role_id: role.id!, + scope_id: scope.id!, + }) + ); + } + } + return Promise.all(queries); +}; +const addRole = async ( + transaction: DatabaseTransactionConnection, + tenantId: string, + role: SeedingRole, + toFill: Record +) => { + toFill[role.name] = await createRole(transaction, tenantId, role); +}; + +const createRoles = async ( + transaction: DatabaseTransactionConnection, + tenantId: string, + scopesLists: ScopesLists +): Promise> => { + const rolesToCreate = fillRoles(scopesLists); + const queries: Array> = []; + const outputList: Record = {}; + for (const role of rolesToCreate) { + queries.push(addRole(transaction, tenantId, role, outputList)); + } + + await Promise.all(queries); + + return outputList; +}; + +const seedRbacPerResource = async ( + transaction: DatabaseTransactionConnection, + tenantId: string, + resourceId: string +): Promise<{ + scopes: ScopesLists; + roles: Record; + relations: SeedingRelation[]; +}> => { + const createdScopes = await createScopesPerResource(transaction, tenantId, resourceId); + const createdRoles = await createRoles(transaction, tenantId, createdScopes); + const createdRelations = await createRelations(transaction, tenantId, createdRoles); + + return { scopes: createdScopes, roles: createdRoles, relations: createdRelations }; +}; + +export const seedResourceRbacData = async ( + transaction: DatabaseTransactionConnection, + tenantId: string, + resources: Record +): Promise< + Record< + string, + { scopes: ScopesLists; roles: Record; relations: SeedingRelation[] } + > +> => { + const payments = await seedRbacPerResource(transaction, tenantId, resources.payments!.id); + + return { payments }; +}; diff --git a/packages/cli/src/commands/database/ogcio/resources.ts b/packages/cli/src/commands/database/ogcio/resources.ts new file mode 100644 index 00000000000..d61de195a8e --- /dev/null +++ b/packages/cli/src/commands/database/ogcio/resources.ts @@ -0,0 +1,68 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ + +import { sql, type DatabaseTransactionConnection } from 'slonik'; + +import { createItem } from './queries.js'; + +const createResource = async ( + transaction: DatabaseTransactionConnection, + tenantId: string, + appToSeed: SeedingResource +) => + createItem({ + transaction, + tenantId, + toInsert: appToSeed, + toLogFieldName: 'name', + itemTypeName: 'Resource', + whereClauses: [sql`indicator = ${appToSeed.indicator}`], + tableName: 'resources', + }); + +const setResourceId = async ( + element: SeedingResource, + transaction: DatabaseTransactionConnection, + tenantId: string +): Promise< + Omit & { + id: string; + } +> => { + const outputValue = await createResource(transaction, tenantId, element); + + return outputValue; +}; + +const createResources = async ( + transaction: DatabaseTransactionConnection, + tenantId: string, + apiIndicator: string +): Promise> => { + const appsToCreate = { payments: fillPaymentsResource(apiIndicator) }; + const outputValues = { + payments: await setResourceId(appsToCreate.payments, transaction, tenantId), + }; + + return outputValues; +}; + +type SeedingResource = { + id?: string; + name: string; + indicator: string; + is_default?: boolean; + access_token_ttl?: number; +}; + +const fillPaymentsResource = (apiIndicator: string): SeedingResource => ({ + name: 'Life Events Payments API', + indicator: apiIndicator, + is_default: false, + access_token_ttl: 3600, +}); + +export const seedResources = async ( + transaction: DatabaseTransactionConnection, + tenantId: string, + apiIndicator: string +) => createResources(transaction, tenantId, apiIndicator);