diff --git a/.gitleaksignore b/.gitleaksignore index dbab12c141e..c6049a9b76b 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -1,4 +1,4 @@ /src/packages/connectors/connector-saml/README.md:private-key:101 -/src/packages/cli/src/commands/database/ogcio/ogcio-seeder.json:generic-api-key:33 -/src/packages/cli/src/commands/database/ogcio/ogcio-seeder.json:generic-api-key:42 -/src/packages/cli/src/commands/database/ogcio/ogcio-seeder.json:generic-api-key:151 \ No newline at end of file +/src/packages/cli/src/commands/database/ogcio/ogcio-seeder-local.json:generic-api-key:37 +/src/packages/cli/src/commands/database/ogcio/ogcio-seeder-local.json:generic-api-key:46 +/src/packages/cli/src/commands/database/ogcio/ogcio-seeder-local.json:generic-api-key:157 diff --git a/README.OGCIO.md b/README.OGCIO.md index e0316dd4935..9a1f976a5e1 100644 --- a/README.OGCIO.md +++ b/README.OGCIO.md @@ -68,7 +68,7 @@ USER_DEFAULT_ORGANIZATION_ROLE_NAMES=OGCIO Employee, OGCIO Manager 3. After the installation, you can start seeding the database. You have to seed in two steps: - seed Logto's database: `pnpm cli db seed` -- seed custom OGCIO data: `npm run cli db ogcio -- --seeder-filepath="./packages/cli/src/commands/database/ogcio/ogcio-seeder.json"` +- seed custom OGCIO data: `npm run cli db ogcio -- --seeder-filepath="./packages/cli/src/commands/database/ogcio/ogcio-seeder-local.json"` 3.5. Database alteration @@ -98,7 +98,7 @@ After installing and seeding the database, you must create a default admin user. ## Custom seeder -We made a custom seeder to ensure the required configuration for OGCIO Building Block integration exists right after the installation. The seeder also makes configuring the deployed Logto instance easy via a CI pipeline. The seeder is located in `packages/cli/src/commands/database/ogcio/`. The configuration for the local dev environment is inside `packages/cli/src/commands/database/ogcio/ogcio-seeder.json`. +We made a custom seeder to ensure the required configuration for OGCIO Building Block integration exists right after the installation. The seeder also makes configuring the deployed Logto instance easy via a CI pipeline. The seeder is located in `packages/cli/src/commands/database/ogcio/`. The configuration for the local dev environment is inside `packages/cli/src/commands/database/ogcio/ogcio-seeder-local.json`. Each type of configuration has its own dedicated file, which serves as a repository of knowledge about the structure of the configuration and how it should be inserted into the database. This approach ensures a systematic and organized management of the database configuration. @@ -112,4 +112,4 @@ This command can take a parameter to specify the input data file, called `seeder Usage: `npm run cli db ogcio -- --seeder-filepath="DATA_FILE_PATH"` -To seed the default data for local dev environments, run `npm run cli db ogcio -- --seeder-filepath="./packages/cli/src/commands/database/ogcio/ogcio-seeder.json"`. +To seed the default data for local dev environments, run `npm run cli db ogcio -- --seeder-filepath="./packages/cli/src/commands/database/ogcio/ogcio-seeder-local.json"`. diff --git a/docker-compose-local.yml b/docker-compose-local.yml index 9ea1f94e67d..a9d5657ad8f 100644 --- a/docker-compose-local.yml +++ b/docker-compose-local.yml @@ -9,7 +9,7 @@ services: [ "sh", "-c", - "npm run cli db seed -- --swe && npm run cli db alteration deploy latest && npm run cli db ogcio -- --seeder-filepath=\"/etc/logto/packages/cli/src/commands/database/ogcio/ogcio-seeder.json\" && npm start" + "npm run cli db seed -- --swe && npm run cli db alteration deploy latest && npm run cli db ogcio -- --seeder-filepath=\"/etc/logto/packages/cli/src/commands/database/ogcio/ogcio-seeder-local.json\" && npm start" ] ports: - 3301:3301 diff --git a/packages/cli/src/commands/database/ogcio/common-rbac.ts b/packages/cli/src/commands/database/ogcio/common-rbac.ts deleted file mode 100644 index 7aa82df891b..00000000000 --- a/packages/cli/src/commands/database/ogcio/common-rbac.ts +++ /dev/null @@ -1,377 +0,0 @@ -/* eslint-disable eslint-comments/disable-enable-pair */ - -/* eslint-disable @silverhand/fp/no-let */ -/* eslint-disable @silverhand/fp/no-mutating-methods */ -/* eslint-disable @silverhand/fp/no-mutation */ -/* eslint-disable @typescript-eslint/no-non-null-assertion */ -/* eslint-disable max-lines */ -/* eslint-disable max-params */ -import { OrganizationRoles, OrganizationScopes, Roles, Scopes } from '@logto/schemas'; -import { sql, type ValueExpression, type DatabaseTransactionConnection } from '@silverhand/slonik'; - -import { - type ResourcePermissionSeeder, - type OrganizationPermissionSeeder, - type OrganizationRoleSeeder, - type ResourceRoleSeeder, -} from './ogcio-seeder.js'; -import { createItem } from './queries.js'; - -type SeedingScope = { - name: string; - id?: string; - description: string; -}; - -export type ResourceSeedingScope = SeedingScope & { - resource_id: string; -}; - -export type OrganizationSeedingScope = SeedingScope; - -type SeedingRole = { - name: string; - description: string; - id?: string; - scopes: T[]; -}; - -export type OrganizationSeedingRole = SeedingRole; - -export type ResourceSeedingRole = SeedingRole; - -export type ScopesLists = { - scopesList: T[]; - scopesByEntity: Record; - scopesByAction: Record; - scopesByFullName: Record; -}; - -export type OrganizationScopesLists = ScopesLists; - -export type ResourceScopesLists = Record>; - -export const buildScopeFullName = (entity: string, action: string, subject?: string): string => - [entity, action, subject].filter(Boolean).join(':'); - -export const ensureRoleHasAtLeastOneScope = (roleName: string, scopes: T[]): void => { - if (scopes.length === 0) { - throw new Error(`${roleName}. You must assign at least one scope`); - } -}; - -export const buildCrossScopes = ( - actions: string[], - entities: string[], - specificPermissions: string[], - scopesLists: ScopesLists -): T[] => { - if (actions.length === 0 && entities.length === 0) { - return []; - } - const scopesByAction = actions.length > 0 ? actions : Object.keys(scopesLists.scopesByAction); - const scopesByEntity = entities.length > 0 ? entities : Object.keys(scopesLists.scopesByEntity); - const byFullname: T[] = []; - for (const action of scopesByAction) { - for (const entity of scopesByEntity) { - const fullName = buildScopeFullName(entity, action); - if ( - scopesLists.scopesByFullName[fullName] !== undefined && - !specificPermissions.includes(fullName) - ) { - byFullname.push(scopesLists.scopesByFullName[fullName]!); - } - } - } - - return byFullname; -}; - -export const getScopesBySpecificPermissions = < - T extends ResourceSeedingScope | OrganizationSeedingScope, ->( - specificPerms: string[] | undefined, - scopesLists: ScopesLists -): T[] => { - if (!specificPerms || specificPerms.length === 0) { - return []; - } - const outputPerms: T[] = []; - for (const scopeName of specificPerms) { - if (!scopesLists.scopesByFullName[scopeName]) { - throw new Error(`Specific permissions. The requested ${scopeName} scope does not exist!`); - } - outputPerms.push(scopesLists.scopesByFullName[scopeName]!); - } - - return outputPerms; -}; - -const addScopeToLists = ( - lists: ScopesLists, - resource: string, - action: string, - resourceId?: string, - subject?: string -) => { - const { scopesByEntity, scopesList, scopesByAction, scopesByFullName } = lists; - - const scope: { name: string; description: string; resource_id?: string } = { - name: buildScopeFullName(resource, action, subject), - description: `${action} ${resource} ${subject}`, - }; - if (resourceId) { - scope.resource_id = resourceId; - } - scopesList.push(scope); - if (scopesByEntity[resource] === undefined) { - scopesByEntity[resource] = []; - } - scopesByEntity[resource]!.push(scope); - if (scopesByAction[action] === undefined) { - scopesByAction[action] = []; - } - scopesByAction[action]!.push(scope); - - scopesByFullName[scope.name] = scope; -}; - -export const fillScopesGroup = < - T extends OrganizationPermissionSeeder | ResourcePermissionSeeder, - U extends ScopesLists, ->( - seeder: T, - fullLists: U, - resourceId?: string -) => { - for (const permission of seeder.specific_permissions ?? []) { - const [resource, action, subject] = permission.split(':'); - if (!resource || !action) { - continue; - } - - addScopeToLists(fullLists, resource, action, resourceId, subject); - } - - for (const resource of seeder.entities ?? []) { - for (const action of seeder.actions ?? []) { - addScopeToLists(fullLists, resource, action, resourceId); - } - } - - return fullLists; -}; - -const isResourceScope = ( - scopeToSeed: ResourceSeedingScope | OrganizationSeedingScope -): scopeToSeed is ResourceSeedingScope => 'resource_id' in scopeToSeed; - -const getScopeConfigByType = ( - scopeToSeed: T -): { tableName: string; whereClauses: ValueExpression[] } => { - if (isResourceScope(scopeToSeed)) { - return { - tableName: Scopes.table, - whereClauses: [ - sql`name = ${scopeToSeed.name}`, - sql`resource_id = ${scopeToSeed.resource_id}`, - ], - }; - } - - return { - tableName: OrganizationScopes.table, - whereClauses: [sql`name = ${scopeToSeed.name}`], - }; -}; - -const createScope = async (params: { - transaction: DatabaseTransactionConnection; - tenantId: string; - scopeToSeed: T; -}) => - createItem({ - transaction: params.transaction, - tenantId: params.tenantId, - toInsert: params.scopeToSeed, - toLogFieldName: 'name', - ...getScopeConfigByType(params.scopeToSeed), - }); - -const isResourceRole = ( - roleToSeed: ResourceSeedingRole | OrganizationSeedingRole -): roleToSeed is ResourceSeedingRole => { - if (roleToSeed.scopes.length === 0) { - throw new Error('You must assign at least one scope to a role!'); - } - - return isResourceScope(roleToSeed.scopes[0]!); -}; - -const isResourceScopeLists = ( - input: OrganizationScopesLists | ResourceScopesLists -): input is ResourceScopesLists => { - if (typeof input !== 'object') { - return false; - } - const resourceKeys = Object.keys(input); - if (resourceKeys.length === 0) { - throw new Error('You must create scopes for at least one resource'); - } - - return true; -}; - -const getListOfScopesToSeed = ( - input: OrganizationScopesLists | ResourceScopesLists -): SeedingScope[] => { - if ('scopesList' in input && Array.isArray(input.scopesList)) { - return input.scopesList; - } - if (isResourceScopeLists(input)) { - const resourceScopesList: ResourceScopesLists = input; - let outputScopes: SeedingScope[] = []; - - for (const scopesGroup of Object.values(resourceScopesList)) { - outputScopes = [...outputScopes, ...scopesGroup.scopesList]; - } - - return outputScopes; - } - throw new Error('List of scopes is not valid'); -}; - -export const getScopesPerRole = ( - roleToSeed: { - name: string; - specific_permissions?: string[]; - actions?: string[]; - entities?: string[]; - }, - scopesLists: ScopesLists -): U[] => { - const inputSpecific = roleToSeed.specific_permissions ?? []; - const specificScopes = getScopesBySpecificPermissions(inputSpecific, scopesLists); - const byAction = roleToSeed.actions ?? []; - const byEntity = roleToSeed.entities ?? []; - ensureRoleHasAtLeastOneScope(roleToSeed.name, [...specificScopes, ...byAction, ...byEntity]); - - const fullList = [ - ...buildCrossScopes(byAction, byEntity, inputSpecific, scopesLists), - ...specificScopes, - ]; - - ensureRoleHasAtLeastOneScope(roleToSeed.name, fullList); - - return fullList; -}; - -export const createScopes = async < - T extends OrganizationPermissionSeeder | ResourcePermissionSeeder, - O extends OrganizationScopesLists | ResourceScopesLists, ->(params: { - transaction: DatabaseTransactionConnection; - tenantId: string; - scopesToSeed: T[]; - fillScopesMethod: (scopesToSeed: T[]) => O; -}): Promise => { - const scopesToCreate = params.fillScopesMethod(params.scopesToSeed); - if (params.scopesToSeed.length === 0) { - return scopesToCreate; - } - const queries: Array< - Promise & { id: string }> - > = []; - for (const element of getListOfScopesToSeed(scopesToCreate)) { - queries.push( - createScope({ - scopeToSeed: element, - transaction: params.transaction, - tenantId: params.tenantId, - }) - ); - } - - await Promise.all(queries); - - return scopesToCreate; -}; - -const getRoleConfigByType = ( - roleToSeed: T -): { tableName: string } => { - if (isResourceRole(roleToSeed)) { - return { - tableName: Roles.table, - }; - } - - return { - tableName: OrganizationRoles.table, - }; -}; - -const createRole = async (params: { - transaction: DatabaseTransactionConnection; - tenantId: string; - roleToSeed: T; -}) => { - const created = await createItem({ - transaction: params.transaction, - tenantId: params.tenantId, - toLogFieldName: 'name', - whereClauses: [sql`name = ${params.roleToSeed.name}`], - toInsert: { name: params.roleToSeed.name, description: params.roleToSeed.description }, - ...getRoleConfigByType(params.roleToSeed), - }); - - params.roleToSeed.id = created.id; - - return { - ...params.roleToSeed, - id: created.id, - }; -}; - -export const addRole = async (params: { - transaction: DatabaseTransactionConnection; - tenantId: string; - role: T; - toFill: Record; -}) => { - params.toFill[params.role.name] = await createRole({ - transaction: params.transaction, - tenantId: params.tenantId, - roleToSeed: params.role, - }); -}; - -export const createRoles = async < - R extends OrganizationRoleSeeder | ResourceRoleSeeder, - T extends OrganizationScopesLists | ResourceScopesLists, - O extends OrganizationSeedingRole | ResourceSeedingRole, ->(params: { - transaction: DatabaseTransactionConnection; - tenantId: string; - scopesLists: T; - rolesToSeed: R[]; - fillRolesMethod: (rolesToSeed: R[], seededScopes: T) => O[]; -}): Promise> => { - const rolesToCreate = params.fillRolesMethod(params.rolesToSeed, params.scopesLists); - const queries: Array> = []; - const outputList: Record = {}; - for (const role of rolesToCreate) { - queries.push( - addRole({ - transaction: params.transaction, - tenantId: params.tenantId, - role, - toFill: outputList, - }) - ); - } - - await Promise.all(queries); - - return outputList; -}; diff --git a/packages/cli/src/commands/database/ogcio/index.ts b/packages/cli/src/commands/database/ogcio/index.ts index c337257aaa3..f6aef1b37ab 100644 --- a/packages/cli/src/commands/database/ogcio/index.ts +++ b/packages/cli/src/commands/database/ogcio/index.ts @@ -12,7 +12,7 @@ import { consoleLog } from '../../../utils.js'; import { setTenantSeederData, type OgcioTenantSeeder } from './ogcio-seeder.js'; import { seedOgcio } from './ogcio.js'; -const DEFAULT_SEEDER_FILE = './src/commands/database/ogcio/ogcio-seeder.json'; +const DEFAULT_SEEDER_FILE = './src/commands/database/ogcio/ogcio-seeder-local.json'; const interpolateString = (content: string): string => { const regExp = /<\w+>/g; diff --git a/packages/cli/src/commands/database/ogcio/ogcio-seeder-local.json b/packages/cli/src/commands/database/ogcio/ogcio-seeder-local.json new file mode 100644 index 00000000000..273ef1be355 --- /dev/null +++ b/packages/cli/src/commands/database/ogcio/ogcio-seeder-local.json @@ -0,0 +1,162 @@ +{ + "default": { + "organizations": [ + { + "name": "OGCIO Seeded Org", + "description": "Organization created through seeder", + "id": "ogcio" + } + ], + "organization_permissions": { + "specific_permissions": [ + "payments:provider:*", + "payments:payment_request:*", + "payments:payment_request.public:read", + "payments:transaction:*" + ] + }, + "organization_roles": [ + { + "name": "Public Servant", + "description": "Building Blocks Public servant", + "specific_permissions": [ + "payments:provider:*", + "payments:payment_request:*", + "payments:payment_request.public:read", + "payments:transaction:*" + ] + } + ], + "applications": [ + { + "name": "Payments Building Block", + "description": "Payments App of Life Events", + "type": "Traditional", + "redirect_uri": "http://localhost:3001/callback", + "logout_redirect_uri": "http://localhost:3001", + "secret": "bgHz4Ouv2lxXCdc6s6s4IUoNpFAklC15", + "id": "2xz6sbi8ch01uhjt1oq8r" + }, + { + "name": "Messaging Building Block", + "description": "Messaging App of Life Events", + "type": "Traditional", + "redirect_uri": "http://localhost:3002/callback", + "logout_redirect_uri": "http://localhost:3002", + "secret": "Y3nBMm8Hs3Ugcl4596w5ewgOosvwX4im", + "id": "4695d8onfb9f3bv18phtq" + } + ], + "resources": [ + { + "id": "payments-api", + "name": "Payments Building Block API", + "indicator": "http://localhost:8001/" + }, + { + "id": "messaging-api", + "name": "Messaging Building Block API", + "indicator": "http://localhost:8002/" + } + ], + "resource_permissions": [ + { + "resource_id": "payments-api", + "specific_permissions": [ + "payments:transaction.self:read", + "payments:payment_request.public:read", + "payments:transaction.self:write", + "payments:provider.public:read" + ] + } + ], + "resource_roles": [ + { + "name": "Citizen", + "description": "A citizen using Life Events and the Building Blocks ecosystem", + "permissions": [ + { + "resource_id": "payments-api", + "specific_permissions": [ + "payments:transaction.self:read", + "payments:payment_request.public:read", + "payments:transaction.self:write", + "payments:provider.public:read" + ] + } + ] + } + ], + "connectors": [ + { + "id": "mygovid", + "sync_profile": false, + "connector_id": "mygovid", + "config": { + "scope": "openid profile email", + "clientId": "mock_client_id", + "clientSecret": "mock_client_secret", + "tokenEndpoint": "http://localhost:3005/logto/mock/token", + "authorizationEndpoint": "http://localhost:3005/logto/mock/auth", + "tokenEndpointAuthMethod": "client_secret_post", + "idTokenVerificationConfig": { + "jwksUri": "http://localhost:3005/logto/mock/keys" + }, + "clientSecretJwtSigningAlgorithm": "HS256" + }, + "metadata": { + "logo": "https://mygovidstatic.blob.core.windows.net/assets/images/favicon_196x196.png", + "name": { + "en": "MyGovId" + }, + "target": "MyGovId (MyGovId connector)" + } + } + ], + "sign_in_experiences": [ + { + "id": "default", + "color": { + "primaryColor": "#007DA6", + "darkPrimaryColor": "#007DA6", + "isDarkModeEnabled": false + }, + "branding": { + "logoUrl": "https://mygovidstatic.blob.core.windows.net/assets/images/helpchat-logo.png", + "darkLogoUrl": "https://mygovidstatic.blob.core.windows.net/assets/images/helpchat-logo.png" + }, + "language_info": { + "autoDetect": true, + "fallbackLanguage": "en" + }, + "sign_in": { + "methods": [] + }, + "sign_up": { + "verify": false, + "password": false, + "identifiers": [] + }, + "social_sign_in_connector_targets": [ + "MyGovId (MyGovId connector)" + ], + "sign_in_mode": "SignInAndRegister" + } + ], + "webhooks": [ + { + "id": "login_webhook", + "name": "User log in", + "events": [ + "PostRegister", + "PostSignIn" + ], + "config": { + "url": "http://localhost:8003/user-login-wh" + }, + "signing_key": "xpWX3WIkPkSA5A0UzLMfNOuTl1qEnbkg", + "enabled": true + } + ] + } +} diff --git a/packages/cli/src/commands/database/ogcio/ogcio-seeder.json b/packages/cli/src/commands/database/ogcio/ogcio-seeder.json index a33424ae1f5..b0dbb11d6d5 100644 --- a/packages/cli/src/commands/database/ogcio/ogcio-seeder.json +++ b/packages/cli/src/commands/database/ogcio/ogcio-seeder.json @@ -2,24 +2,28 @@ "default": { "organizations": [ { - "name": "OGCIO Seeded Org", - "description": "Organization created through seeder", + "name": "OGCIO", + "description": "OGCIO Organization", "id": "ogcio" } ], - "organization_permissions": [ - { - "specific_permissions": [ - "payments:create:providers" - ] - } - ], + "organization_permissions": { + "specific_permissions": [ + "payments:provider:*", + "payments:payment_request:*", + "payments:payment_request.public:read", + "payments:transaction:*" + ] + }, "organization_roles": [ { - "name": "Public servant", + "name": "Public Servant", "description": "Building Blocks Public servant", "specific_permissions": [ - "payments:create:providers" + "payments:provider:*", + "payments:payment_request:*", + "payments:payment_request.public:read", + "payments:transaction:*" ] } ], @@ -28,40 +32,41 @@ "name": "Payments Building Block", "description": "Payments App of Life Events", "type": "Traditional", - "redirect_uri": "http://localhost:3001/callback", - "logout_redirect_uri": "http://localhost:3001", - "secret": "bgHz4Ouv2lxXCdc6s6s4IUoNpFAklC15", - "id": "2xz6sbi8ch01uhjt1oq8r" + "redirect_uri": "", + "logout_redirect_uri": "", + "secret": "", + "id": "r5f56tpkytpqyyshiutd2" }, { "name": "Messaging Building Block", "description": "Messaging App of Life Events", "type": "Traditional", - "redirect_uri": "http://localhost:3002/callback", - "logout_redirect_uri": "http://localhost:3002", - "secret": "Y3nBMm8Hs3Ugcl4596w5ewgOosvwX4im", - "id": "4695d8onfb9f3bv18phtq" + "redirect_uri": "", + "logout_redirect_uri": "", + "secret": "", + "id": "1lvmteh2ao3xrswyq7j3e" } ], "resources": [ { "id": "payments-api", "name": "Payments Building Block API", - "indicator": "http://localhost:8001/" + "indicator": "" }, { "id": "messaging-api", "name": "Messaging Building Block API", - "indicator": "http://localhost:8002/" + "indicator": "" } ], "resource_permissions": [ { - "for_resource_ids": [ - "payments-api" - ], + "resource_id": "payments-api", "specific_permissions": [ - "payments:create:payment" + "payments:transaction.self:read", + "payments:payment_request.public:read", + "payments:transaction.self:write", + "payments:provider.public:read" ] } ], @@ -71,11 +76,12 @@ "description": "A citizen using Life Events and the Building Blocks ecosystem", "permissions": [ { - "for_resource_ids": [ - "payments-api" - ], + "resource_id": "payments-api", "specific_permissions": [ - "payments:create:payment" + "payments:transaction.self:read", + "payments:payment_request.public:read", + "payments:transaction.self:write", + "payments:provider.public:read" ] } ] @@ -88,13 +94,13 @@ "connector_id": "mygovid", "config": { "scope": "openid profile email", - "clientId": "mock_client_id", - "clientSecret": "mock_client_secret", - "tokenEndpoint": "http://localhost:3005/logto/mock/token", - "authorizationEndpoint": "http://localhost:3005/logto/mock/auth", + "clientId": "", + "clientSecret": "", + "tokenEndpoint": "", + "authorizationEndpoint": "", "tokenEndpointAuthMethod": "client_secret_post", "idTokenVerificationConfig": { - "jwksUri": "http://localhost:3005/logto/mock/keys" + "jwksUri": "" }, "clientSecretJwtSigningAlgorithm": "HS256" }, @@ -146,9 +152,9 @@ "PostSignIn" ], "config": { - "url": "http://localhost:8003/user-login-wh" + "url": "" }, - "signing_key": "xpWX3WIkPkSA5A0UzLMfNOuTl1qEnbkg", + "signing_key": "", "enabled": true } ] diff --git a/packages/cli/src/commands/database/ogcio/ogcio-seeder.ts b/packages/cli/src/commands/database/ogcio/ogcio-seeder.ts index 01b4b52f74a..9210935157a 100644 --- a/packages/cli/src/commands/database/ogcio/ogcio-seeder.ts +++ b/packages/cli/src/commands/database/ogcio/ogcio-seeder.ts @@ -5,7 +5,7 @@ export type OgcioTenantSeeder = Record; export type OgcioSeeder = { organizations?: OrganizationSeeder[]; - organization_permissions?: OrganizationPermissionSeeder[]; + organization_permissions?: OrganizationPermissionSeeder; organization_roles?: OrganizationRoleSeeder[]; applications?: ApplicationSeeder[]; resources?: ResourceSeeder[]; @@ -23,16 +23,12 @@ export type OrganizationSeeder = { }; export type OrganizationPermissionSeeder = { - specific_permissions?: string[]; - actions?: string[]; - entities?: string[]; + specific_permissions: string[]; }; export type OrganizationRoleSeeder = { name: string; - actions?: string[]; - entities?: string[]; - specific_permissions?: string[]; + specific_permissions: string[]; description: string; }; @@ -103,10 +99,8 @@ export type SignInExperienceSeeder = { }; export type ResourcePermissionSeeder = { - for_resource_ids: string[]; - specific_permissions?: string[]; - actions?: string[]; - entities?: string[]; + resource_id: string; + specific_permissions: string[]; }; export type ResourceRoleSeeder = { @@ -116,10 +110,8 @@ export type ResourceRoleSeeder = { }; export type ScopePerResourceRoleSeeder = { - for_resource_ids: string[]; - actions?: string[]; - entities?: string[]; - specific_permissions?: string[]; + resource_id: string; + specific_permissions: string[]; description: string; }; diff --git a/packages/cli/src/commands/database/ogcio/ogcio.ts b/packages/cli/src/commands/database/ogcio/ogcio.ts index 2e6c355dff7..4871c85c359 100644 --- a/packages/cli/src/commands/database/ogcio/ogcio.ts +++ b/packages/cli/src/commands/database/ogcio/ogcio.ts @@ -27,7 +27,7 @@ const createDataForTenant = async ( }); } - const organizationsRbac = await seedOrganizationRbacData({ + await seedOrganizationRbacData({ transaction, tenantId, toSeed: tenantData, @@ -48,7 +48,7 @@ const createDataForTenant = async ( inputResources: tenantData.resources, }); - const resourcesRbac = await seedResourceRbacData({ + await seedResourceRbacData({ tenantId, transaction, toSeed: tenantData, diff --git a/packages/cli/src/commands/database/ogcio/organizations-rbac.ts b/packages/cli/src/commands/database/ogcio/organizations-rbac.ts index 1691681c4cd..a786a87e24e 100644 --- a/packages/cli/src/commands/database/ogcio/organizations-rbac.ts +++ b/packages/cli/src/commands/database/ogcio/organizations-rbac.ts @@ -2,48 +2,123 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @silverhand/fp/no-mutating-methods */ -import { OrganizationRoleScopeRelations } from '@logto/schemas'; +import { OrganizationRoles, OrganizationRoleScopeRelations, OrganizationScopes } from '@logto/schemas'; import { sql, type DatabaseTransactionConnection } from '@silverhand/slonik'; - -import { - type OrganizationSeedingRole, - type OrganizationScopesLists, - createScopes, - fillScopesGroup, - getScopesPerRole, - createRoles, -} from './common-rbac.js'; import { type OrganizationPermissionSeeder, type OrganizationRoleSeeder } from './ogcio-seeder.js'; -import { createItemWithoutId } from './queries.js'; +import { createItem, createItemWithoutId } from './queries.js'; + +type SeedingScope = { + name: string; + id?: string; + description: string; +}; +type ScopesByName = Record; + +type SeedingRole = { + name: string; + id?: string; + description: string; + scopes: string[]; +}; type SeedingRelation = { organization_role_id: string; organization_scope_id: string }; -const fillScopes = (scopesToSeed: OrganizationPermissionSeeder[]): OrganizationScopesLists => { - const fullLists: OrganizationScopesLists = { - scopesList: [], - scopesByEntity: {}, - scopesByAction: {}, - scopesByFullName: {}, - }; +const createScope = async (params: { + transaction: DatabaseTransactionConnection; + tenantId: string; + scopeToSeed: SeedingScope; +}) => + createItem({ + transaction: params.transaction, + tenantId: params.tenantId, + toInsert: params.scopeToSeed, + toLogFieldName: 'name', + tableName: OrganizationScopes.table, + whereClauses: [sql`name = ${params.scopeToSeed.name}`], + }); - for (const singleSeeder of scopesToSeed) { - fillScopesGroup(singleSeeder, fullLists); - } +const buildScopes = ( + scopes: string[] +): ScopesByName => { + return scopes.reduce((acc, scopeName) => { + acc[scopeName] = { + name: scopeName, + description: scopeName + }; + return acc; + }, {}); +}; + +export const createScopes = async (params: { + transaction: DatabaseTransactionConnection; + tenantId: string; + scopesToSeed: OrganizationPermissionSeeder; +}) => { + const scopesToCreate = buildScopes(params.scopesToSeed.specific_permissions); + + const queries = Object.values(scopesToCreate).map((scope) => + createScope({ + transaction: params.transaction, + tenantId: params.tenantId, + scopeToSeed: scope + }) + ); + + await Promise.all(queries); - return fullLists; + return scopesToCreate; }; -const fillRole = ( - roleToSeed: OrganizationRoleSeeder, - scopesLists: OrganizationScopesLists -): OrganizationSeedingRole => ({ - name: roleToSeed.name, - description: roleToSeed.description, - scopes: getScopesPerRole(roleToSeed, scopesLists), -}); +const createRole = async (params: { + transaction: DatabaseTransactionConnection; + tenantId: string; + roleToSeed: { + name: string; + description: string; + id?: string; + } +}) => { + const created = await createItem({ + transaction: params.transaction, + tenantId: params.tenantId, + toLogFieldName: 'name', + whereClauses: [sql`name = ${params.roleToSeed.name}`], + toInsert: { name: params.roleToSeed.name, description: params.roleToSeed.description }, + tableName: OrganizationRoles.table, + }); + + params.roleToSeed.id = created.id; + + return { + ...params.roleToSeed, + id: created.id, + }; +} -const fillRoles = (rolesToSeed: OrganizationRoleSeeder[], scopesLists: OrganizationScopesLists) => - rolesToSeed.map((role) => fillRole(role, scopesLists)); +const createRoles = async (params: { + transaction: DatabaseTransactionConnection; + tenantId: string; + scopes: ScopesByName, + rolesToSeed: OrganizationRoleSeeder[] +}) => { + const rolesToCreate = params.rolesToSeed.map((role) =>({ + name: role.name, + description: role.description, + scopes: role.specific_permissions + })); + + const queries = rolesToCreate.map((role) => + createRole({ + transaction: params.transaction, + tenantId: params.tenantId, + roleToSeed: role + }) + ); + + await Promise.all(queries); + + return rolesToCreate; +} const createRoleScopeRelation = async ( transaction: DatabaseTransactionConnection, @@ -66,19 +141,16 @@ const createRoleScopeRelation = async ( const createRelations = async ( transaction: DatabaseTransactionConnection, tenantId: string, - roles: Record + scopes: ScopesByName, + roles: SeedingRole[] ) => { - 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!, - }) - ); - } - } + const queries = roles.flatMap((role) => + role.scopes.map((scope) => createRoleScopeRelation(transaction, tenantId, { + organization_role_id: role.id!, + organization_scope_id: scopes[scope]?.id! + })) + ); + return Promise.all(queries); }; @@ -86,42 +158,29 @@ export const seedOrganizationRbacData = async (params: { transaction: DatabaseTransactionConnection; tenantId: string; toSeed: { - organization_permissions?: OrganizationPermissionSeeder[]; + organization_permissions?: OrganizationPermissionSeeder; organization_roles?: OrganizationRoleSeeder[]; }; -}): Promise<{ - scopes: OrganizationScopesLists; - roles: Record; - relations: SeedingRelation[]; -}> => { - if (params.toSeed.organization_permissions?.length && params.toSeed.organization_roles?.length) { +}) => { + if (params.toSeed.organization_permissions && params.toSeed.organization_roles?.length) { const createdScopes = await createScopes({ transaction: params.transaction, tenantId: params.tenantId, scopesToSeed: params.toSeed.organization_permissions, - fillScopesMethod: fillScopes, }); - + const createdRoles = await createRoles({ transaction: params.transaction, tenantId: params.tenantId, - scopesLists: createdScopes, - rolesToSeed: params.toSeed.organization_roles, - fillRolesMethod: fillRoles, + scopes: createdScopes, + rolesToSeed: params.toSeed.organization_roles }); - const relations = await createRelations(params.transaction, params.tenantId, createdRoles); - return { scopes: createdScopes, roles: createdRoles, relations }; + await createRelations( + params.transaction, + params.tenantId, + createdScopes, + createdRoles + ); } - - return { - scopes: { - scopesList: [], - scopesByEntity: {}, - scopesByAction: {}, - scopesByFullName: {}, - }, - roles: {}, - relations: [], - }; }; diff --git a/packages/cli/src/commands/database/ogcio/resources-rbac.ts b/packages/cli/src/commands/database/ogcio/resources-rbac.ts index b2ada091496..fcda585e7ee 100644 --- a/packages/cli/src/commands/database/ogcio/resources-rbac.ts +++ b/packages/cli/src/commands/database/ogcio/resources-rbac.ts @@ -3,56 +3,145 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @silverhand/fp/no-mutating-methods */ /* eslint-disable @silverhand/fp/no-mutation */ -import { RolesScopes } from '@logto/schemas'; +import { Roles, RolesScopes, Scopes } from '@logto/schemas'; import { sql, type DatabaseTransactionConnection } from '@silverhand/slonik'; - -import { - type ResourceSeedingRole, - type ResourceSeedingScope, - type ResourceScopesLists, - createScopes, - createRoles, - fillScopesGroup, - getScopesPerRole, - ensureRoleHasAtLeastOneScope, - type ScopesLists, -} from './common-rbac.js'; -import { type ResourceRoleSeeder, type ResourcePermissionSeeder } from './ogcio-seeder.js'; +import { type ResourceRoleSeeder, type ResourcePermissionSeeder, ScopePerResourceRoleSeeder } from './ogcio-seeder.js'; import { createItem } from './queries.js'; import { type SeedingResource } from './resources.js'; -const getFullListOfScopesPerRole = ( - roleToSeed: ResourceRoleSeeder, - scopesLists: ResourceScopesLists -): ResourceSeedingScope[] => { - ensureRoleHasAtLeastOneScope(roleToSeed.name, roleToSeed.permissions); - let fullList: ResourceSeedingScope[] = []; - for (const permissionsGroup of roleToSeed.permissions) { - for (const resource of permissionsGroup.for_resource_ids) { - fullList = [ - ...fullList, - ...getScopesPerRole({ name: roleToSeed.name, ...permissionsGroup }, scopesLists[resource]!), - ]; - } +type SeedingScope = { + name: string; + id?: string; + resource_id: string; + description: string; +}; +type ScopesByName = Record; +type ScopesByResourceId = Record; +type SeededRole = { + id?: string; + name: string; + description: string; + scopes: ScopePerResourceRoleSeeder[] +} +type SeedingRelation = { role_id: string; scope_id: string; id?: string }; + +const createScope = async (params: { + transaction: DatabaseTransactionConnection; + tenantId: string; + scopeToSeed: SeedingScope; +}) => + createItem({ + transaction: params.transaction, + tenantId: params.tenantId, + toInsert: params.scopeToSeed, + toLogFieldName: 'name', + tableName: Scopes.table, + whereClauses: [ + sql`name = ${params.scopeToSeed.name}`, + sql`resource_id = ${params.scopeToSeed.resource_id}` + ] + }); + +const buildScopes = ( + resourceId: string, + scopes: string[] +): ScopesByName => { + return scopes.reduce((acc, scopeName) => { + acc[scopeName] = { + name: scopeName, + description: scopeName, + resource_id: resourceId + }; + return acc; + }, {}); +}; + +export const createScopes = async (params: { + transaction: DatabaseTransactionConnection; + tenantId: string; + scopesToSeed: ResourcePermissionSeeder[]; +}) => { + if (params.scopesToSeed.length === 0) { + return {}; + } + + const scopesToCreate: ScopesByResourceId = {}; + for (const singleSeeder of params.scopesToSeed) { + scopesToCreate[singleSeeder.resource_id] = buildScopes(singleSeeder.resource_id, singleSeeder.specific_permissions); } - ensureRoleHasAtLeastOneScope(roleToSeed.name, fullList); - return fullList; + const queries: Array< + Promise & { id: string }> + > = []; + + + Object.values(scopesToCreate).forEach((scopes) => { + Object.values(scopes).forEach((scope) => { + queries.push( + createScope({ + scopeToSeed: scope, + transaction: params.transaction, + tenantId: params.tenantId, + }) + ); + }) + }); + + await Promise.all(queries); + + return scopesToCreate; }; -const fillRole = ( - roleToSeed: ResourceRoleSeeder, - scopesLists: ResourceScopesLists -): ResourceSeedingRole => ({ - name: roleToSeed.name, - description: roleToSeed.description, - scopes: getFullListOfScopesPerRole(roleToSeed, scopesLists), -}); +const createRole = async (params: { + transaction: DatabaseTransactionConnection; + tenantId: string; + roleToSeed: { + name: string; + description: string; + id?: string; + } +}) => { + const created = await createItem({ + transaction: params.transaction, + tenantId: params.tenantId, + toLogFieldName: 'name', + whereClauses: [sql`name = ${params.roleToSeed.name}`], + toInsert: { name: params.roleToSeed.name, description: params.roleToSeed.description }, + tableName: Roles.table, + }); -const fillRoles = (rolesToSeed: ResourceRoleSeeder[], scopesLists: ResourceScopesLists) => - rolesToSeed.map((role) => fillRole(role, scopesLists)); + params.roleToSeed.id = created.id; -type SeedingRelation = { role_id: string; scope_id: string; id?: string }; + return { + ...params.roleToSeed, + id: created.id, + }; +} + +const createRoles = async (params: { + transaction: DatabaseTransactionConnection; + tenantId: string; + scopes: ScopesByResourceId, + rolesToSeed: ResourceRoleSeeder[] +}) => { + const rolesToCreate = params.rolesToSeed.map((role) =>({ + name: role.name, + description: role.description, + scopes: role.permissions + })); + + const queries = rolesToCreate.map((role) => + createRole({ + transaction: params.transaction, + tenantId: params.tenantId, + roleToSeed: role + }) + ); + + await Promise.all(queries); + + return rolesToCreate; +} const createRoleScopeRelation = async ( transaction: DatabaseTransactionConnection, @@ -71,21 +160,41 @@ const createRoleScopeRelation = async ( const createRelations = async (params: { transaction: DatabaseTransactionConnection; tenantId: string; - roles: Record; + roles: SeededRole[]; + scopes: ScopesByResourceId; }) => { - const queries: Array> = []; - for (const role of Object.values(params.roles)) { - for (const scope of role.scopes) { - queries.push( - createRoleScopeRelation(params.transaction, params.tenantId, { + const relationsToCrete: SeedingRelation[] = []; + + params.roles.forEach((role) => { + role.scopes.forEach((scopeGroup) => { + const relations = scopeGroup.specific_permissions.map((permission) => { + // @ts-ignore @typescript-eslint/no-non-null-asserted-optional-chain + if (params.scopes[scopeGroup.resource_id]?.[permission]?.id === undefined) { + throw new Error("Requested permission does not exist.") + } + + return { role_id: role.id!, - scope_id: scope.id!, - }) - ); - } - } - return Promise.all(queries); -}; + // @ts-ignore @typescript-eslint/no-non-null-asserted-optional-chain + scope_id: params.scopes[scopeGroup.resource_id]?.[permission]?.id! + } + }); + relationsToCrete.push(...relations); + }) + }); + + const queries = relationsToCrete.map((relation) => + createRoleScopeRelation( + params.transaction, + params.tenantId, + relation + ) + ); + + await Promise.all(queries); + + return relationsToCrete; +} const replaceWithResourceIdFromDatabase = ( seededResources: Record, @@ -99,32 +208,26 @@ const replaceWithResourceIdFromDatabase = ( } => { if (toSeed.resource_permissions?.length) { for (const permission of toSeed.resource_permissions) { - const toSetResourceIds = []; - for (const resourceId of permission.for_resource_ids) { - if (!seededResources[resourceId]) { - throw new Error( - `Resource scopes. Referring to a not existent resource id: ${resourceId}!` - ); - } - toSetResourceIds.push(seededResources[resourceId]!.id!); + const seededResourceId = seededResources[permission.resource_id]; + if (!seededResourceId) { + throw new Error( + `Resource scopes. Referring to a not existent resource id: ${permission.resource_id}!` + ); } - permission.for_resource_ids = toSetResourceIds; + permission.resource_id = seededResourceId.id!; } } if (toSeed.resource_roles?.length) { for (const roles of toSeed.resource_roles) { - const toSetResourceIds = []; for (const permissionGroup of roles.permissions) { - for (const resourceId of permissionGroup.for_resource_ids) { - if (!seededResources[resourceId]) { - throw new Error( - `Resource roles. Referring to a not existent resource id: ${resourceId}!` - ); - } - toSetResourceIds.push(seededResources[resourceId]!.id!); + const seededResourceId = seededResources[permissionGroup.resource_id]; + if (!seededResourceId) { + throw new Error( + `Resource roles. Referring to a not existent resource id: ${permissionGroup.resource_id}!` + ); } - permissionGroup.for_resource_ids = toSetResourceIds; + permissionGroup.resource_id = seededResourceId.id!; } } } @@ -140,53 +243,27 @@ export const seedResourceRbacData = async (params: { resource_permissions?: ResourcePermissionSeeder[]; resource_roles?: ResourceRoleSeeder[]; }; -}): Promise<{ - scopes: ResourceScopesLists; - roles: Record; - relations: SeedingRelation[]; -}> => { +}) => { params.toSeed = replaceWithResourceIdFromDatabase(params.seededResources, params.toSeed); if (params.toSeed.resource_permissions?.length && params.toSeed.resource_roles?.length) { const createdScopes = await createScopes({ transaction: params.transaction, tenantId: params.tenantId, - scopesToSeed: params.toSeed.resource_permissions, - fillScopesMethod: fillScopes, + scopesToSeed: params.toSeed.resource_permissions }); + const createdRoles = await createRoles({ transaction: params.transaction, tenantId: params.tenantId, - scopesLists: createdScopes, + scopes: createdScopes, rolesToSeed: params.toSeed.resource_roles, - fillRolesMethod: fillRoles, }); - const createdRelations = await createRelations({ + + await createRelations({ transaction: params.transaction, tenantId: params.tenantId, roles: createdRoles, + scopes: createdScopes }); - - return { scopes: createdScopes, roles: createdRoles, relations: createdRelations }; - } - - return { scopes: {}, roles: {}, relations: [] }; -}; - -const getEmptyList = (): ScopesLists => ({ - scopesList: [], - scopesByEntity: {}, - scopesByAction: {}, - scopesByFullName: {}, -}); - -const fillScopes = (scopesToSeed: ResourcePermissionSeeder[]): ResourceScopesLists => { - const fullLists: ResourceScopesLists = {}; - for (const singleSeeder of scopesToSeed) { - for (const resource of singleSeeder.for_resource_ids) { - fullLists[resource] = getEmptyList(); - fillScopesGroup(singleSeeder, fullLists[resource]!, resource); - } } - - return fullLists; }; diff --git a/packages/core/src/libraries/ogcio-user.ts b/packages/core/src/libraries/ogcio-user.ts index efdbc0ddd38..d916acff866 100644 --- a/packages/core/src/libraries/ogcio-user.ts +++ b/packages/core/src/libraries/ogcio-user.ts @@ -134,20 +134,73 @@ const getDomainFromEmail = (email: string): string | undefined => { return email.split('@')[1]; }; -const getUserRoleByDomain = async ( - domain: string, - getRoles: (roleName: string, excludeRoleId?: string) => Promise +const assignCitizenRole = async ( + user: User, + getRoles: (roleName: string, excludeRoleId?: string) => Promise, + insertUsersRoles: (usersRoles: CreateUsersRole[]) => Promise> ) => { - if (PUBLIC_SERVANT_DOMAINS.has(domain)) { - return getRoles('Public Servant'); + const userRole = await getRoles('Citizen'); + + if (userRole === undefined) { + consoleLog.error(phrases.en.errors.role.default_role_missing); + return; + } + + return insertUsersRoles([ + { + tenantId: user.tenantId, + id: generateStandardId(), + userId: user.id, + roleId: userRole.id, + }, + ]); +}; + +const assignUserToOrganization = async (user: User, organizationQueries: OrganizationQueries) => { + try { + const organization = await organizationQueries.findById('ogcio'); + await organizationQueries.relations.users.insert([organization.id, user.id]); + return organization; + } catch { + consoleLog.error(phrases.en.errors.entity.not_exists_with_id); } - return getRoles('Citizen'); +}; + +const assignOrganizationRoleToUser = async ( + user: User, + organization: Organization, + organizationQueries: OrganizationQueries +) => { + const allOrganizationRoles = await organizationQueries.roles.findAll(100, 0); + const publicServantRole = allOrganizationRoles[1].find((role) => role.name === 'Public Servant'); + + if (publicServantRole === undefined) { + consoleLog.error(phrases.en.errors.role.default_role_missing); + return; + } + + await organizationQueries.relations.rolesUsers.insert([ + organization.id, + publicServantRole.id, + user.id, + ]); +}; + +const assignPublicServantRole = async (user: User, organizationQueries: OrganizationQueries) => { + const organization = await assignUserToOrganization(user, organizationQueries); + + if (!organization) { + return; + } + + await assignOrganizationRoleToUser(user, organization, organizationQueries); }; export const manageDefaultUserRole = async ( user: User, getRoles: (roleName: string, excludeRoleId?: string) => Promise, - insertUsersRoles: (usersRoles: CreateUsersRole[]) => Promise> + insertUsersRoles: (usersRoles: CreateUsersRole[]) => Promise>, + organizationQueries: OrganizationQueries ) => { if (user.tenantId === adminTenantId) { return; @@ -165,19 +218,9 @@ export const manageDefaultUserRole = async ( return; } - const userRole = await getUserRoleByDomain(domain, getRoles); - - if (userRole === undefined) { - consoleLog.error(phrases.en.errors.role.default_role_missing); - return; + if (PUBLIC_SERVANT_DOMAINS.has(domain)) { + return assignPublicServantRole(user, organizationQueries); } - return insertUsersRoles([ - { - tenantId: user.tenantId, - id: generateStandardId(), - userId: user.id, - roleId: userRole.id, - }, - ]); + return assignCitizenRole(user, getRoles, insertUsersRoles); }; diff --git a/packages/core/src/routes/interaction/actions/submit-interaction.ts b/packages/core/src/routes/interaction/actions/submit-interaction.ts index 638fb4bb690..988da35cd0e 100644 --- a/packages/core/src/routes/interaction/actions/submit-interaction.ts +++ b/packages/core/src/routes/interaction/actions/submit-interaction.ts @@ -21,7 +21,7 @@ import { conditional, conditionalArray, trySafe } from '@silverhand/essentials'; import { EnvSet } from '#src/env-set/index.js'; // OGCIO -import { manageDefaultOrganizations, manageDefaultUserRole } from '#src/libraries/ogcio-user.js'; +import { manageDefaultUserRole } from '#src/libraries/ogcio-user.js'; import { assignInteractionResults } from '#src/libraries/session.js'; import { encryptUserPassword } from '#src/libraries/user.js'; import type { LogEntry, WithLogContext } from '#src/middleware/koa-audit-log.js'; @@ -171,10 +171,6 @@ async function handleSubmitRegister( getInitialUserRoles(isInAdminTenant, isCreatingFirstAdminUser, isCloud) ); - // OGCIO - // @ts-expect-error weird error at findRoleByRoleName return type - await manageDefaultUserRole(user, roles.findRoleByRoleName, usersRoles.insertUsersRoles); - if (isCreatingFirstAdminUser) { // In OSS, we need to limit sign-in experience to "sign-in only" once // the first admin has been create since we don't want other unexpected registrations @@ -193,7 +189,17 @@ async function handleSubmitRegister( ]); } else { // OGCIO - await manageDefaultOrganizations({ userId: id, organizationQueries: organizations }); + // DO NOT DELETE THIS! It is disabled for now. + // await manageDefaultOrganizations({ userId: id, organizationQueries: organizations }); + + // OGCIO + await manageDefaultUserRole( + user, + // @ts-expect-error: strange error in roles.findRoleByRoleName return type + roles.findRoleByRoleName, + usersRoles.insertUsersRoles, + organizations + ); } await assignInteractionResults(ctx, provider, { login: { accountId: id } });