From d154f68a59c3123d2f968f973e8bf2b70aeaac43 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Sat, 30 Dec 2023 19:32:03 +0530 Subject: [PATCH] feat(infisical-pg: completed machine identity services --- backend-pg/.eslintrc.js | 1 + backend-pg/src/@types/knex.d.ts | 49 ++ .../db/migrations/20231228074856_identity.ts | 21 + .../20231228074908_identity-universal-auth.ts | 49 ++ .../20231228075011_identity-access-token.ts | 35 ++ .../20231228075023_identity-membership.ts | 44 ++ backend-pg/src/db/schemas/identities.ts | 20 + .../src/db/schemas/identity-access-tokens.ts | 28 ++ .../db/schemas/identity-org-memberships.ts | 22 + .../schemas/identity-project-memberships.ts | 22 + .../db/schemas/identity-ua-client-secrets.ts | 29 ++ .../db/schemas/identity-universal-auths.ts | 27 + backend-pg/src/db/schemas/index.ts | 6 + backend-pg/src/db/schemas/models.ts | 11 + .../ee/services/permission/org-permission.ts | 49 +- .../services/permission/permission-service.ts | 94 +++- .../services/permission/project-permission.ts | 26 +- backend-pg/src/lib/casl/index.ts | 44 ++ backend-pg/src/lib/errors/index.ts | 16 +- backend-pg/src/lib/ip/index.ts | 137 ++++++ backend-pg/src/lib/types/index.ts | 6 + backend-pg/src/server/routes/index.ts | 2 +- backend-pg/src/services/auth/auth-type.ts | 3 +- .../identity-access-token-dal.ts | 10 + .../identity-access-token-service.ts | 93 ++++ .../identity-access-token-types.ts | 3 + .../identity-project/identity-project-dal.ts | 10 + .../identity-project-service.ts | 186 +++++++ .../identity-project-types.ts | 17 + .../src/services/identity/identity-dal.ts | 10 + .../src/services/identity/identity-org-dal.ts | 35 ++ .../src/services/identity/identity-service.ts | 149 ++++++ .../src/services/identity/identity-types.ts | 16 + .../universal-auth/ua-client-secret-dal.ts | 27 + .../universal-auth/universal-auth-dal.ts | 11 + .../universal-auth/universal-auth-service.ts | 463 ++++++++++++++++++ .../universal-auth/universal-auth-types.ts | 39 ++ 37 files changed, 1747 insertions(+), 63 deletions(-) create mode 100644 backend-pg/src/db/migrations/20231228074856_identity.ts create mode 100644 backend-pg/src/db/migrations/20231228074908_identity-universal-auth.ts create mode 100644 backend-pg/src/db/migrations/20231228075011_identity-access-token.ts create mode 100644 backend-pg/src/db/migrations/20231228075023_identity-membership.ts create mode 100644 backend-pg/src/db/schemas/identities.ts create mode 100644 backend-pg/src/db/schemas/identity-access-tokens.ts create mode 100644 backend-pg/src/db/schemas/identity-org-memberships.ts create mode 100644 backend-pg/src/db/schemas/identity-project-memberships.ts create mode 100644 backend-pg/src/db/schemas/identity-ua-client-secrets.ts create mode 100644 backend-pg/src/db/schemas/identity-universal-auths.ts create mode 100644 backend-pg/src/lib/casl/index.ts create mode 100644 backend-pg/src/lib/ip/index.ts create mode 100644 backend-pg/src/services/identity-access-token/identity-access-token-dal.ts create mode 100644 backend-pg/src/services/identity-access-token/identity-access-token-service.ts create mode 100644 backend-pg/src/services/identity-access-token/identity-access-token-types.ts create mode 100644 backend-pg/src/services/identity-project/identity-project-dal.ts create mode 100644 backend-pg/src/services/identity-project/identity-project-service.ts create mode 100644 backend-pg/src/services/identity-project/identity-project-types.ts create mode 100644 backend-pg/src/services/identity/identity-dal.ts create mode 100644 backend-pg/src/services/identity/identity-org-dal.ts create mode 100644 backend-pg/src/services/identity/identity-service.ts create mode 100644 backend-pg/src/services/identity/identity-types.ts create mode 100644 backend-pg/src/services/universal-auth/ua-client-secret-dal.ts create mode 100644 backend-pg/src/services/universal-auth/universal-auth-dal.ts create mode 100644 backend-pg/src/services/universal-auth/universal-auth-service.ts create mode 100644 backend-pg/src/services/universal-auth/universal-auth-types.ts diff --git a/backend-pg/.eslintrc.js b/backend-pg/.eslintrc.js index 416116742..71d58d9cb 100644 --- a/backend-pg/.eslintrc.js +++ b/backend-pg/.eslintrc.js @@ -16,6 +16,7 @@ module.exports = { "consistent-return": "off", // my style "import/order": "off", // for simple-import-order "import/prefer-default-export": "off", // why + "no-restricted-syntax": "off", "import/first": "error", "import/newline-after-import": "error", "import/no-duplicates": "error", diff --git a/backend-pg/src/@types/knex.d.ts b/backend-pg/src/@types/knex.d.ts index b380ebbed..e1fcd6dae 100644 --- a/backend-pg/src/@types/knex.d.ts +++ b/backend-pg/src/@types/knex.d.ts @@ -13,6 +13,24 @@ import { TBackupPrivateKey, TBackupPrivateKeyInsert, TBackupPrivateKeyUpdate, + TIdentities, + TIdentitiesInsert, + TIdentitiesUpdate, + TIdentityAccessTokens, + TIdentityAccessTokensInsert, + TIdentityAccessTokensUpdate, + TIdentityOrgMemberships, + TIdentityOrgMembershipsInsert, + TIdentityOrgMembershipsUpdate, + TIdentityProjectMemberships, + TIdentityProjectMembershipsInsert, + TIdentityProjectMembershipsUpdate, + TIdentityUaClientSecrets, + TIdentityUaClientSecretsInsert, + TIdentityUaClientSecretsUpdate, + TIdentityUniversalAuths, + TIdentityUniversalAuthsInsert, + TIdentityUniversalAuthsUpdate, TIncidentContacts, TIncidentContactsInsert, TIncidentContactsUpdate, @@ -208,6 +226,37 @@ declare module "knex/types/tables" { TIntegrationAuthsInsert, TIntegrationAuthsUpdate >; + [TableName.Identity]: Knex.CompositeTableType< + TIdentities, + TIdentitiesInsert, + TIdentitiesUpdate + >; + [TableName.IdentityUniversalAuth]: Knex.CompositeTableType< + TIdentityUniversalAuths, + TIdentityUniversalAuthsInsert, + TIdentityUniversalAuthsUpdate + >; + [TableName.IdentityUaClientSecret]: Knex.CompositeTableType< + TIdentityUaClientSecrets, + TIdentityUaClientSecretsInsert, + TIdentityUaClientSecretsUpdate + >; + [TableName.IdentityAccessToken]: Knex.CompositeTableType< + TIdentityAccessTokens, + TIdentityAccessTokensInsert, + TIdentityAccessTokensUpdate + >; + [TableName.IdentityOrgMembership]: Knex.CompositeTableType< + TIdentityOrgMemberships, + TIdentityOrgMembershipsInsert, + TIdentityOrgMembershipsUpdate + >; + [TableName.IdentityProjectMembership]: Knex.CompositeTableType< + TIdentityProjectMemberships, + TIdentityProjectMembershipsInsert, + TIdentityProjectMembershipsUpdate + >; + // Junction tables [TableName.JnSecretTag]: Knex.CompositeTableType< TSecretTagJunction, TSecretTagJunctionInsert, diff --git a/backend-pg/src/db/migrations/20231228074856_identity.ts b/backend-pg/src/db/migrations/20231228074856_identity.ts new file mode 100644 index 000000000..56f5c6752 --- /dev/null +++ b/backend-pg/src/db/migrations/20231228074856_identity.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.Identity))) { + await knex.schema.createTable(TableName.Identity, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name").notNullable(); + t.string("authMethod"); + t.timestamps(true, true, true); + }); + } + await createOnUpdateTrigger(knex, TableName.Identity); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.Identity); + await dropOnUpdateTrigger(knex, TableName.Identity); +} diff --git a/backend-pg/src/db/migrations/20231228074908_identity-universal-auth.ts b/backend-pg/src/db/migrations/20231228074908_identity-universal-auth.ts new file mode 100644 index 000000000..15270fe94 --- /dev/null +++ b/backend-pg/src/db/migrations/20231228074908_identity-universal-auth.ts @@ -0,0 +1,49 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.IdentityUniversalAuth))) { + await knex.schema.createTable(TableName.IdentityUniversalAuth, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("clientId").notNullable(); + t.integer("accessTokenTTL").defaultTo(7200).notNullable(); + t.integer("accessTokenMaxTTL").defaultTo(7200).notNullable(); + t.integer("accessTokenNumUsesLimit").defaultTo(0).notNullable(); + t.jsonb("clientSecretTrustedIps").notNullable(); + t.jsonb("accessTokenTrustedIps").notNullable(); + t.timestamps(true, true, true); + t.uuid("identityId").notNullable().unique(); + t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + }); + } + if (!(await knex.schema.hasTable(TableName.IdentityUaClientSecret))) { + await knex.schema.createTable(TableName.IdentityUaClientSecret, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("description").notNullable(); + t.string("clientSecretPrefix").notNullable(); + t.string("clientSecretHash").notNullable(); + t.datetime("clientSecretLastUsedAt"); + t.integer("clientSecretNumUses").defaultTo(0).notNullable(); + t.integer("clientSecretNumUsesLimit").defaultTo(0).notNullable(); + t.integer("clientSecretTTL").defaultTo(0).notNullable(); + t.boolean("isClientSecretRevoked").defaultTo(false).notNullable(); + t.timestamps(true, true, true); + t.uuid("identityUAId").notNullable(); + t.foreign("identityUAId") + .references("id") + .inTable(TableName.IdentityUniversalAuth) + .onDelete("CASCADE"); + }); + } + await createOnUpdateTrigger(knex, TableName.IdentityUniversalAuth); + await createOnUpdateTrigger(knex, TableName.IdentityUaClientSecret); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IdentityUaClientSecret); + await knex.schema.dropTableIfExists(TableName.IdentityUniversalAuth); + await dropOnUpdateTrigger(knex, TableName.IdentityUaClientSecret); + await dropOnUpdateTrigger(knex, TableName.IdentityUniversalAuth); +} diff --git a/backend-pg/src/db/migrations/20231228075011_identity-access-token.ts b/backend-pg/src/db/migrations/20231228075011_identity-access-token.ts new file mode 100644 index 000000000..1a5953fcd --- /dev/null +++ b/backend-pg/src/db/migrations/20231228075011_identity-access-token.ts @@ -0,0 +1,35 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.IdentityAccessToken))) { + await knex.schema.createTable(TableName.IdentityAccessToken, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("authType").notNullable(); + t.integer("accessTokenTTL").defaultTo(2592000).notNullable(); // 30 days second + t.integer("accessTokenMaxTTL").defaultTo(2592000).notNullable(); + t.integer("accessTokenNumUses").defaultTo(0).notNullable(); + t.integer("accessTokenNumUsesLimit").defaultTo(0).notNullable(); + t.datetime("accessTokenLastUsedAt"); + t.datetime("accessTokenLastRenewedAt"); + t.boolean("isAccessTokenRevoked").defaultTo(false).notNullable(); + t.uuid("identityUAClientSecretId"); + t.foreign("identityUAClientSecretId") + .references("id") + .inTable(TableName.IdentityUaClientSecret) + .onDelete("CASCADE"); + t.uuid("identityId").notNullable(); + t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.IdentityAccessToken); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IdentityAccessToken); + await dropOnUpdateTrigger(knex, TableName.IdentityAccessToken); +} diff --git a/backend-pg/src/db/migrations/20231228075023_identity-membership.ts b/backend-pg/src/db/migrations/20231228075023_identity-membership.ts new file mode 100644 index 000000000..3c0e56a2a --- /dev/null +++ b/backend-pg/src/db/migrations/20231228075023_identity-membership.ts @@ -0,0 +1,44 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.IdentityOrgMembership))) { + await knex.schema.createTable(TableName.IdentityOrgMembership, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("role").notNullable(); + t.uuid("roleId"); + t.foreign("roleId").references("id").inTable(TableName.OrgRoles); + t.uuid("orgId").notNullable(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.timestamps(true, true, true); + t.uuid("identityId").notNullable(); + t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + }); + } + await createOnUpdateTrigger(knex, TableName.IdentityOrgMembership); + + if (!(await knex.schema.hasTable(TableName.IdentityProjectMembership))) { + await knex.schema.createTable(TableName.IdentityProjectMembership, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("role").notNullable(); + t.uuid("roleId"); + t.foreign("roleId").references("id").inTable(TableName.ProjectRoles); + t.uuid("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.uuid("identityId").notNullable(); + t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + await createOnUpdateTrigger(knex, TableName.IdentityProjectMembership); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IdentityOrgMembership); + await knex.schema.dropTableIfExists(TableName.IdentityProjectMembership); + + await dropOnUpdateTrigger(knex, TableName.IdentityProjectMembership); + await dropOnUpdateTrigger(knex, TableName.IdentityOrgMembership); +} diff --git a/backend-pg/src/db/schemas/identities.ts b/backend-pg/src/db/schemas/identities.ts new file mode 100644 index 000000000..b8ff6c36f --- /dev/null +++ b/backend-pg/src/db/schemas/identities.ts @@ -0,0 +1,20 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const IdentitiesSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + authMethod: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date(), +}); + +export type TIdentities = z.infer; +export type TIdentitiesInsert = Omit; +export type TIdentitiesUpdate = Partial>; diff --git a/backend-pg/src/db/schemas/identity-access-tokens.ts b/backend-pg/src/db/schemas/identity-access-tokens.ts new file mode 100644 index 000000000..1d8405427 --- /dev/null +++ b/backend-pg/src/db/schemas/identity-access-tokens.ts @@ -0,0 +1,28 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const IdentityAccessTokensSchema = z.object({ + id: z.string().uuid(), + authType: z.string(), + accessTokenTTL: z.number().default(2592000), + accessTokenMaxTTL: z.number().default(2592000), + accessTokenNumUses: z.number().default(0), + accessTokenNumUsesLimit: z.number().default(0), + accessTokenLastUsedAt: z.date().nullable().optional(), + accessTokenLastRenewedAt: z.date().nullable().optional(), + isAccessTokenRevoked: z.boolean().default(false), + identityUAClientSecretId: z.string().uuid().nullable().optional(), + identityId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), +}); + +export type TIdentityAccessTokens = z.infer; +export type TIdentityAccessTokensInsert = Omit; +export type TIdentityAccessTokensUpdate = Partial>; diff --git a/backend-pg/src/db/schemas/identity-org-memberships.ts b/backend-pg/src/db/schemas/identity-org-memberships.ts new file mode 100644 index 000000000..c5c9a0f61 --- /dev/null +++ b/backend-pg/src/db/schemas/identity-org-memberships.ts @@ -0,0 +1,22 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const IdentityOrgMembershipsSchema = z.object({ + id: z.string().uuid(), + role: z.string(), + roleId: z.string().uuid().nullable().optional(), + orgId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + identityId: z.string().uuid(), +}); + +export type TIdentityOrgMemberships = z.infer; +export type TIdentityOrgMembershipsInsert = Omit; +export type TIdentityOrgMembershipsUpdate = Partial>; diff --git a/backend-pg/src/db/schemas/identity-project-memberships.ts b/backend-pg/src/db/schemas/identity-project-memberships.ts new file mode 100644 index 000000000..f631a10e0 --- /dev/null +++ b/backend-pg/src/db/schemas/identity-project-memberships.ts @@ -0,0 +1,22 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const IdentityProjectMembershipsSchema = z.object({ + id: z.string().uuid(), + role: z.string(), + roleId: z.string().uuid().nullable().optional(), + projectId: z.string().uuid(), + identityId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), +}); + +export type TIdentityProjectMemberships = z.infer; +export type TIdentityProjectMembershipsInsert = Omit; +export type TIdentityProjectMembershipsUpdate = Partial>; diff --git a/backend-pg/src/db/schemas/identity-ua-client-secrets.ts b/backend-pg/src/db/schemas/identity-ua-client-secrets.ts new file mode 100644 index 000000000..dc3a5e9ec --- /dev/null +++ b/backend-pg/src/db/schemas/identity-ua-client-secrets.ts @@ -0,0 +1,29 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const IdentityUaClientSecretsSchema = z.object({ + id: z.string().uuid(), + description: z.string().nullable().optional(), + clientSecretPrefix: z.string(), + clientSecretHash: z.string(), + clientSecretLastUsedAt: z.date().nullable().optional(), + clientSecretNumUses: z.number().default(0), + clientSecretNumUsesLimit: z.number().default(0), + clientSecretTTL: z.number().default(0), + isClientSecretRevoked: z.boolean().default(false), + createdAt: z.date(), + updatedAt: z.date(), + identityUAId: z.string().uuid() +}); + +export type TIdentityUaClientSecrets = z.infer; +export type TIdentityUaClientSecretsInsert = Omit; +export type TIdentityUaClientSecretsUpdate = Partial< + Omit +>; diff --git a/backend-pg/src/db/schemas/identity-universal-auths.ts b/backend-pg/src/db/schemas/identity-universal-auths.ts new file mode 100644 index 000000000..41398f143 --- /dev/null +++ b/backend-pg/src/db/schemas/identity-universal-auths.ts @@ -0,0 +1,27 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const IdentityUniversalAuthsSchema = z.object({ + id: z.string().uuid(), + clientId: z.string(), + accessTokenTTL: z.number().default(7200), + accessTokenMaxTTL: z.number().default(7200), + accessTokenNumUsesLimit: z.number().default(0), + clientSecretTrustedIps: z.unknown(), + accessTokenTrustedIps: z.unknown(), + createdAt: z.date(), + updatedAt: z.date(), + identityId: z.string().uuid() +}); + +export type TIdentityUniversalAuths = z.infer; +export type TIdentityUniversalAuthsInsert = Omit; +export type TIdentityUniversalAuthsUpdate = Partial< + Omit +>; diff --git a/backend-pg/src/db/schemas/index.ts b/backend-pg/src/db/schemas/index.ts index 94cca7613..b47e18c54 100644 --- a/backend-pg/src/db/schemas/index.ts +++ b/backend-pg/src/db/schemas/index.ts @@ -2,6 +2,12 @@ export * from "./api-keys"; export * from "./auth-token-sessions"; export * from "./auth-tokens"; export * from "./backup-private-key"; +export * from "./identities"; +export * from "./identity-access-tokens"; +export * from "./identity-org-memberships"; +export * from "./identity-project-memberships"; +export * from "./identity-ua-client-secrets"; +export * from "./identity-universal-auths"; export * from "./incident-contacts"; export * from "./integration-auths"; export * from "./integrations"; diff --git a/backend-pg/src/db/schemas/models.ts b/backend-pg/src/db/schemas/models.ts index fac7f6058..52fb7b08c 100644 --- a/backend-pg/src/db/schemas/models.ts +++ b/backend-pg/src/db/schemas/models.ts @@ -29,6 +29,12 @@ export enum TableName { IntegrationAuth = "integration_auths", ServiceToken = "service_tokens", Webhook = "webhooks", + Identity = "identities", + IdentityAccessToken = "identity_access_tokens", + IdentityUniversalAuth = "identity_universal_auths", + IdentityUaClientSecret = "identity_ua_client_secrets", + IdentityOrgMembership = "identity_org_memberships", + IdentityProjectMembership = " identity_project_memberships", JnSecretTag = "secret_tag_junction", JnSecretVersionTag = "secret_version_tag_junction" } @@ -53,6 +59,7 @@ export const ServiceTokenScopes = z export enum OrgMembershipRole { Admin = "admin", Member = "member", + NoAccess = "no-access", Custom = "custom" } @@ -83,3 +90,7 @@ export enum SecretType { Shared = "shared", Personal = "personal" } + +export enum IdentityAuthMethod { + Univeral = "universal" +} diff --git a/backend-pg/src/ee/services/permission/org-permission.ts b/backend-pg/src/ee/services/permission/org-permission.ts index 2fc929489..cc18af8ae 100644 --- a/backend-pg/src/ee/services/permission/org-permission.ts +++ b/backend-pg/src/ee/services/permission/org-permission.ts @@ -1,28 +1,6 @@ -import { - AbilityBuilder, - buildMongoQueryMatcher, - createMongoAbility, - MongoAbility -} from "@casl/ability"; -import { FieldCondition, FieldInstruction, JsInterpreter } from "@ucast/mongo2js"; -import picomatch from "picomatch"; +import { AbilityBuilder, createMongoAbility, MongoAbility } from "@casl/ability"; -const $glob: FieldInstruction = { - type: "field", - validate(instruction, value) { - if (typeof value !== "string") { - throw new Error(`"${instruction.name}" expects value to be a string`); - } - } -}; - -const glob: JsInterpreter> = (node, object, context) => { - const secretPath = context.get(object, node.field); - const permissionSecretGlobPath = node.value; - return picomatch.isMatch(secretPath, permissionSecretGlobPath, { strictSlashes: false }); -}; - -export const conditionsMatcher = buildMongoQueryMatcher({ $glob }, { glob }); +import { conditionsMatcher } from "@app/lib/casl"; export enum OrgPermissionActions { Read = "read", @@ -39,7 +17,8 @@ export enum OrgPermissionSubjects { IncidentAccount = "incident-contact", Sso = "sso", Billing = "billing", - SecretScanning = "secret-scanning" + SecretScanning = "secret-scanning", + Identity = "identity" } export type OrgPermissionSet = @@ -51,7 +30,8 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount] | [OrgPermissionActions, OrgPermissionSubjects.Sso] | [OrgPermissionActions, OrgPermissionSubjects.SecretScanning] - | [OrgPermissionActions, OrgPermissionSubjects.Billing]; + | [OrgPermissionActions, OrgPermissionSubjects.Billing] + | [OrgPermissionActions, OrgPermissionSubjects.Identity]; const buildAdminPermission = () => { const { can, build } = new AbilityBuilder>(createMongoAbility); @@ -94,6 +74,11 @@ const buildAdminPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing); can(OrgPermissionActions.Delete, OrgPermissionSubjects.Billing); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.Identity); + return build({ conditionsMatcher }); }; @@ -117,7 +102,19 @@ const buildMemberPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.SecretScanning); can(OrgPermissionActions.Delete, OrgPermissionSubjects.SecretScanning); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.Identity); + return build({ conditionsMatcher }); }; export const orgMemberPermissions = buildMemberPermission(); + +const buildNoAccessPermission = () => { + const { build } = new AbilityBuilder>(createMongoAbility); + return build({ conditionsMatcher }); +}; + +export const orgNoAccessPermissions = buildNoAccessPermission(); diff --git a/backend-pg/src/ee/services/permission/permission-service.ts b/backend-pg/src/ee/services/permission/permission-service.ts index 2dcf70157..9392efd56 100644 --- a/backend-pg/src/ee/services/permission/permission-service.ts +++ b/backend-pg/src/ee/services/permission/permission-service.ts @@ -1,14 +1,17 @@ import { createMongoAbility, MongoAbility, RawRuleOf } from "@casl/ability"; -import { unpackRules } from "@casl/ability/extra"; +import { PackRule, unpackRules } from "@casl/ability/extra"; -import { ProjectMembershipRole } from "@app/db/schemas"; +import { OrgMembershipRole, ProjectMembershipRole } from "@app/db/schemas"; +import { conditionsMatcher } from "@app/lib/casl"; import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; import { ActorType } from "@app/services/auth/auth-type"; +import { TOrgRoleDalFactory } from "@app/services/org/org-role-dal"; +import { TProjectRoleDalFactory } from "@app/services/project-role/project-role-dal"; import { - conditionsMatcher, orgAdminPermissions, orgMemberPermissions, + orgNoAccessPermissions, OrgPermissionSet } from "./org-permission"; import { TPermissionDalFactory } from "./permission-dal"; @@ -21,12 +24,18 @@ import { } from "./project-permission"; type TPermissionServiceFactoryDep = { + orgRoleDal: Pick; + projectRoleDal: Pick; permissionDal: TPermissionDalFactory; }; export type TPermissionServiceFactory = ReturnType; -export const permissionServiceFactory = ({ permissionDal }: TPermissionServiceFactoryDep) => { +export const permissionServiceFactory = ({ + permissionDal, + orgRoleDal, + projectRoleDal +}: TPermissionServiceFactoryDep) => { /* * Get user permission in an organization * */ @@ -37,9 +46,14 @@ export const permissionServiceFactory = ({ permissionDal }: TPermissionServiceFa throw new BadRequestError({ name: "Custom permission not found" }); } - if (membership.role === "admin") return { permission: orgAdminPermissions, membership }; - if (membership.role === "member") return { permission: orgMemberPermissions, membership }; - if (membership.role === "custom") { + if (membership.role === OrgMembershipRole.Admin) + return { permission: orgAdminPermissions, membership }; + if (membership.role === OrgMembershipRole.Member) + return { permission: orgMemberPermissions, membership }; + + if (membership.role === OrgMembershipRole.NoAccess) + return { permission: orgNoAccessPermissions, membership }; + if (membership.role === OrgMembershipRole.Custom) { const permission = createMongoAbility( // akhilmhdh: putting any due to ts incompatiable matching with string and the other unpackRules>>(membership.permissions as any), @@ -57,7 +71,7 @@ export const permissionServiceFactory = ({ permissionDal }: TPermissionServiceFa const getUserProjectPermission = async (userId: string, projectId: string) => { const membership = await permissionDal.getProjectPermission(userId, projectId); if (!membership) throw new UnauthorizedError({ name: "User not in org" }); - if (membership.role === "custom" && !membership.permissions) { + if (membership.role === ProjectMembershipRole.Custom && !membership.permissions) { throw new BadRequestError({ name: "Custom permission not found" }); } @@ -107,10 +121,72 @@ export const permissionServiceFactory = ({ permissionDal }: TPermissionServiceFa } }; + const getOrgPermissionByRole = async (role: string, orgId: string) => { + const isCustomRole = !Object.values(OrgMembershipRole).includes(role as OrgMembershipRole); + if (isCustomRole) { + const orgRole = await orgRoleDal.findOne({ slug: role, orgId }); + if (!orgRole) throw new BadRequestError({ message: "Role not found" }); + return { + permission: createMongoAbility( + unpackRules>>( + (orgRole.permissions as PackRule>>[]) || [] + ), + { + conditionsMatcher + } + ), + role: orgRole + }; + } + switch (role) { + case OrgMembershipRole.Admin: + return { permission: orgAdminPermissions }; + case OrgMembershipRole.Member: + return { permission: orgMemberPermissions }; + case OrgMembershipRole.NoAccess: + return { permission: orgNoAccessPermissions }; + default: + throw new BadRequestError({ message: "Org role not found" }); + } + }; + + const getProjectPermissionByRole = async (role: string, projectId: string) => { + const isCustomRole = !Object.values(OrgMembershipRole).includes(role as OrgMembershipRole); + if (isCustomRole) { + const projectRole = await projectRoleDal.findOne({ slug: role, projectId }); + if (!projectRole) throw new BadRequestError({ message: "Role not found" }); + return { + permission: createMongoAbility( + unpackRules>>( + (projectRole.permissions as PackRule< + RawRuleOf> + >[]) || [] + ), + { + conditionsMatcher + } + ), + role: projectRole + }; + } + switch (role) { + case ProjectMembershipRole.Admin: + return { permission: projectAdminPermissions }; + case ProjectMembershipRole.Member: + return { permission: projectMemberPermissions }; + case ProjectMembershipRole.NoAccess: + return { permission: projectNoAccessPermissions }; + default: + throw new BadRequestError({ message: "Org role not found" }); + } + }; + return { getUserOrgPermission, getOrgPermission, getUserProjectPermission, - getProjectPermission + getProjectPermission, + getOrgPermissionByRole, + getProjectPermissionByRole }; }; diff --git a/backend-pg/src/ee/services/permission/project-permission.ts b/backend-pg/src/ee/services/permission/project-permission.ts index e28aa9c00..5b92c8f81 100644 --- a/backend-pg/src/ee/services/permission/project-permission.ts +++ b/backend-pg/src/ee/services/permission/project-permission.ts @@ -1,28 +1,6 @@ -import { - AbilityBuilder, - buildMongoQueryMatcher, - createMongoAbility, - ForcedSubject, - MongoAbility} from "@casl/ability"; -import { FieldCondition, FieldInstruction, JsInterpreter } from "@ucast/mongo2js"; -import picomatch from "picomatch"; +import { AbilityBuilder, createMongoAbility, ForcedSubject, MongoAbility } from "@casl/ability"; -const $glob: FieldInstruction = { - type: "field", - validate(instruction, value) { - if (typeof value !== "string") { - throw new Error(`"${instruction.name}" expects value to be a string`); - } - } -}; - -const glob: JsInterpreter> = (node, object, context) => { - const secretPath = context.get(object, node.field); - const permissionSecretGlobPath = node.value; - return picomatch.isMatch(secretPath, permissionSecretGlobPath, { strictSlashes: false }); -}; - -export const conditionsMatcher = buildMongoQueryMatcher({ $glob }, { glob }); +import { conditionsMatcher } from "@app/lib/casl"; export enum ProjectPermissionActions { Read = "read", diff --git a/backend-pg/src/lib/casl/index.ts b/backend-pg/src/lib/casl/index.ts new file mode 100644 index 000000000..1e853cc4d --- /dev/null +++ b/backend-pg/src/lib/casl/index.ts @@ -0,0 +1,44 @@ +import { buildMongoQueryMatcher, MongoAbility } from "@casl/ability"; +import { FieldCondition, FieldInstruction, JsInterpreter } from "@ucast/mongo2js"; +import picomatch from "picomatch"; + +const $glob: FieldInstruction = { + type: "field", + validate(instruction, value) { + if (typeof value !== "string") { + throw new Error(`"${instruction.name}" expects value to be a string`); + } + } +}; + +const glob: JsInterpreter> = (node, object, context) => { + const secretPath = context.get(object, node.field); + const permissionSecretGlobPath = node.value; + return picomatch.isMatch(secretPath, permissionSecretGlobPath, { strictSlashes: false }); +}; + +export const conditionsMatcher = buildMongoQueryMatcher({ $glob }, { glob }); + +/** + * Extracts and formats permissions from a CASL Ability object or a raw permission set. + */ +const extractPermissions = (ability: MongoAbility) => + ability.rules.map((permission) => `${permission.action}_${permission.subject}`); + +/** + * Compares two sets of permissions to determine if the first set is at least as privileged as the second set. + * The function checks if all permissions in the second set are contained within the first set and if the first set has equal or more permissions. + * + */ +export const isAtLeastAsPrivileged = (permissions1: MongoAbility, permissions2: MongoAbility) => { + const set1 = new Set(extractPermissions(permissions1)); + const set2 = new Set(extractPermissions(permissions2)); + + for (const perm of set2) { + if (!set1.has(perm)) { + return false; + } + } + + return set1.size >= set2.size; +}; diff --git a/backend-pg/src/lib/errors/index.ts b/backend-pg/src/lib/errors/index.ts index 78726d9e9..0865c1267 100644 --- a/backend-pg/src/lib/errors/index.ts +++ b/backend-pg/src/lib/errors/index.ts @@ -16,9 +16,21 @@ export class UnauthorizedError extends Error { error: unknown; - constructor({ name, error, message }: { message?: string; name: string; error?: unknown }) { + constructor({ name, error, message }: { message?: string; name?: string; error?: unknown } = {}) { super(message ?? "You are not allowed to access this resourve"); - this.name = name; + this.name = name || "UnauthorizedError"; + this.error = error; + } +} + +export class ForbiddenRequestError extends Error { + name: string; + + error: unknown; + + constructor({ name, error, message }: { message?: string; name?: string; error?: unknown }) { + super(message ?? "You are not allowed to access this resourve"); + this.name = name || "ForbideenError"; this.error = error; } } diff --git a/backend-pg/src/lib/ip/index.ts b/backend-pg/src/lib/ip/index.ts new file mode 100644 index 000000000..6c9326118 --- /dev/null +++ b/backend-pg/src/lib/ip/index.ts @@ -0,0 +1,137 @@ +import net from "node:net"; + +import { UnauthorizedError } from "../errors"; + +export enum IPType { + IPV4 = "ipv4", + IPV6 = "ipv6" +} + +/** + * Return details of IP [ip]: + * - If [ip] is a specific IP address then return the IPv4/IPv6 address + * - If [ip] is a subnet then return the network IPv4/IPv6 address and prefix + */ +export const extractIPDetails = (ip: string) => { + if (net.isIPv4(ip)) + return { + ipAddress: ip, + type: IPType.IPV4 + }; + + if (net.isIPv6(ip)) + return { + ipAddress: ip, + type: IPType.IPV6 + }; + + const [ipNet, prefix] = ip.split("/"); + + let type; + switch (net.isIP(ipNet)) { + case 4: + type = IPType.IPV4; + break; + case 6: + type = IPType.IPV6; + break; + default: + throw new Error("Failed to extract IP details"); + } + + return { + ipAddress: ipNet, + type, + prefix: parseInt(prefix, 10) + }; +}; + +/** + * Checks if a given string is a valid CIDR block. + * + * The function checks if the input string is a valid IPv4 or IPv6 address in CIDR notation. + * + * CIDR notation includes a network address followed by a slash ('/') and a prefix length. + * For IPv4, the prefix length must be between 0 and 32. For IPv6, it must be between 0 and 128. + * If the input string is not a valid CIDR block, the function returns `false`. + * + */ +export const isValidCidr = (cidr: string): boolean => { + const [ip, prefix] = cidr.split("/"); + + const prefixNum = parseInt(prefix, 10); + + // ensure prefix exists and is a number within the appropriate range for each IP version + if ( + !prefix || + Number.isNaN(prefixNum) || + (net.isIPv4(ip) && (prefixNum < 0 || prefixNum > 32)) || + (net.isIPv6(ip) && (prefixNum < 0 || prefixNum > 128)) + ) { + return false; + } + + // ensure the IP portion of the CIDR block is a valid IPv4 or IPv6 address + if (!net.isIPv4(ip) && !net.isIPv6(ip)) { + return false; + } + + return true; +}; + +/** + * Checks if a given string is a valid IPv4/IPv6 address or a valid CIDR block. + * + * If the string contains a slash ('/'), it treats the input as a CIDR block and checks its validity. + * Otherwise, it treats the string as a standalone IP address (either IPv4 or IPv6) and checks its validity. + * + * @param {string} input - The string to be checked. It could be an IP address or a CIDR block. + * @returns {boolean} Returns `true` if the string is a valid IP address (either IPv4 or IPv6) or a valid CIDR block, `false` otherwise. + * + */ +export const isValidIpOrCidr = (ip: string): boolean => { + // if the string contains a slash, treat it as a CIDR block + if (ip.includes("/")) { + return isValidCidr(ip); + } + + // otherwise, treat it as a standalone IP address + if (net.isIPv4(ip) || net.isIPv6(ip)) { + return true; + } + + return false; +}; + +/** + * Validates the IP address [ipAddress] against the trusted IPs [trustedIps]. + */ +export const checkIPAgainstBlocklist = ({ + ipAddress, + trustedIps +}: { + ipAddress: string; + trustedIps: { + ipAddress: string; + type: IPType; + prefix: number; + }[]; +}) => { + const blockList = new net.BlockList(); + + for (const trustedIp of trustedIps) { + if (trustedIp.prefix !== undefined) { + blockList.addSubnet(trustedIp.ipAddress, trustedIp.prefix, trustedIp.type); + } else { + blockList.addAddress(trustedIp.ipAddress, trustedIp.type); + } + } + + const { type } = extractIPDetails(ipAddress); + const check = blockList.check(ipAddress, type); + + if (!check) + throw new UnauthorizedError({ + message: "Failed to authenticate" + }); +}; diff --git a/backend-pg/src/lib/types/index.ts b/backend-pg/src/lib/types/index.ts index 2674c64f9..5c194d789 100644 --- a/backend-pg/src/lib/types/index.ts +++ b/backend-pg/src/lib/types/index.ts @@ -1,5 +1,11 @@ import { ActorType } from "@app/services/auth/auth-type"; +export type TOrgPermission = { + actor: ActorType; + actorId: string; + orgId: string; +}; + export type TProjectPermission = { actor: ActorType; actorId: string; diff --git a/backend-pg/src/server/routes/index.ts b/backend-pg/src/server/routes/index.ts index f8915841c..4469fadc6 100644 --- a/backend-pg/src/server/routes/index.ts +++ b/backend-pg/src/server/routes/index.ts @@ -90,7 +90,7 @@ export const registerRoutes = async ( const permissionDal = permissionDalFactory(db); // ee services - const permissionService = permissionServiceFactory({ permissionDal }); + const permissionService = permissionServiceFactory({ permissionDal, orgRoleDal, projectRoleDal }); // service layers const tokenService = tokenServiceFactory({ tokenDal: authTokenDal }); diff --git a/backend-pg/src/services/auth/auth-type.ts b/backend-pg/src/services/auth/auth-type.ts index ebfade21c..dc9840d96 100644 --- a/backend-pg/src/services/auth/auth-type.ts +++ b/backend-pg/src/services/auth/auth-type.ts @@ -16,7 +16,8 @@ export enum AuthTokenType { PROVIDER_TOKEN = "providerToken", // TODO: remove in favor of claim API_KEY = "apiKey", SERVICE_ACCESS_TOKEN = "serviceAccessToken", - SERVICE_REFRESH_TOKEN = "serviceRefreshToken" + SERVICE_REFRESH_TOKEN = "serviceRefreshToken", + IDENTITY_ACCESS_TOKEN = "identityAccessToken" } export enum AuthMode { diff --git a/backend-pg/src/services/identity-access-token/identity-access-token-dal.ts b/backend-pg/src/services/identity-access-token/identity-access-token-dal.ts new file mode 100644 index 000000000..dd1bf511e --- /dev/null +++ b/backend-pg/src/services/identity-access-token/identity-access-token-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TIdentityAccessTokenDalFactory = ReturnType; + +export const identityAccessTokenDalFactory = (db: TDbClient) => { + const identityAccessTokenOrm = ormify(db, TableName.IdentityAccessToken); + return identityAccessTokenOrm; +}; diff --git a/backend-pg/src/services/identity-access-token/identity-access-token-service.ts b/backend-pg/src/services/identity-access-token/identity-access-token-service.ts new file mode 100644 index 000000000..f66e3b7e7 --- /dev/null +++ b/backend-pg/src/services/identity-access-token/identity-access-token-service.ts @@ -0,0 +1,93 @@ +import jwt, { JwtPayload } from "jsonwebtoken"; + +import { getConfig } from "@app/lib/config/env"; +import { UnauthorizedError } from "@app/lib/errors"; + +import { AuthTokenType } from "../auth/auth-type"; +import { TIdentityAccessTokenDalFactory } from "./identity-access-token-dal"; +import { TRenewAccessTokenDTO } from "./identity-access-token-types"; + +type TIdentityAccessTokenServiceFactoryDep = { + identityAccessTokenDal: TIdentityAccessTokenDalFactory; +}; + +export type TIdentityAccessTokenServiceFactory = ReturnType< + typeof identityAccessTokenServiceFactory +>; + +export const identityAccessTokenServiceFactory = ({ + identityAccessTokenDal +}: TIdentityAccessTokenServiceFactoryDep) => { + const renewAccessToken = async ({ accessToken }: TRenewAccessTokenDTO) => { + const appCfg = getConfig(); + + const decodedToken = jwt.verify(accessToken, appCfg.JWT_AUTH_SECRET) as JwtPayload; + if (decodedToken.authTokenType !== AuthTokenType.IDENTITY_ACCESS_TOKEN) + throw new UnauthorizedError(); + + const identityAccessToken = await identityAccessTokenDal.findOne({ + id: decodedToken.identityAccessTokenId, + isAccessTokenRevoked: false + }); + if (!identityAccessToken) throw new UnauthorizedError(); + + const { + accessTokenTTL, + accessTokenLastRenewedAt, + accessTokenMaxTTL, + createdAt: accessTokenCreatedAt + } = identityAccessToken; + // ttl check + if (accessTokenTTL > 0) { + const currentDate = new Date(); + if (accessTokenLastRenewedAt) { + // access token has been renewed + const accessTokenRenewed = new Date(accessTokenLastRenewedAt); + const ttlInMilliseconds = accessTokenTTL * 1000; + const expirationDate = new Date(accessTokenRenewed.getTime() + ttlInMilliseconds); + + if (currentDate > expirationDate) + throw new UnauthorizedError({ + message: "Failed to renew MI access token due to TTL expiration" + }); + } else { + // access token has never been renewed + const accessTokenCreated = new Date(accessTokenCreatedAt); + const ttlInMilliseconds = accessTokenTTL * 1000; + const expirationDate = new Date(accessTokenCreated.getTime() + ttlInMilliseconds); + + if (currentDate > expirationDate) + throw new UnauthorizedError({ + message: "Failed to renew MI access token due to TTL expiration" + }); + } + } + + // max ttl checks + if (accessTokenMaxTTL > 0) { + const accessTokenCreated = new Date(accessTokenCreatedAt); + const ttlInMilliseconds = accessTokenMaxTTL * 1000; + const currentDate = new Date(); + const expirationDate = new Date(accessTokenCreated.getTime() + ttlInMilliseconds); + + if (currentDate > expirationDate) + throw new UnauthorizedError({ + message: "Failed to renew MI access token due to Max TTL expiration" + }); + + const extendToDate = new Date(currentDate.getTime() + accessTokenTTL); + if (extendToDate > expirationDate) + throw new UnauthorizedError({ + message: "Failed to renew MI access token past its Max TTL expiration" + }); + } + + await identityAccessTokenDal.updateById(identityAccessToken.id, { + accessTokenLastRenewedAt: new Date() + }); + + return identityAccessTokenDal; + }; + + return { renewAccessToken }; +}; diff --git a/backend-pg/src/services/identity-access-token/identity-access-token-types.ts b/backend-pg/src/services/identity-access-token/identity-access-token-types.ts new file mode 100644 index 000000000..8b80c37c7 --- /dev/null +++ b/backend-pg/src/services/identity-access-token/identity-access-token-types.ts @@ -0,0 +1,3 @@ +export type TRenewAccessTokenDTO = { + accessToken: string; +}; diff --git a/backend-pg/src/services/identity-project/identity-project-dal.ts b/backend-pg/src/services/identity-project/identity-project-dal.ts new file mode 100644 index 000000000..7622cc6df --- /dev/null +++ b/backend-pg/src/services/identity-project/identity-project-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TIdentityProjectDalFactory = ReturnType; + +export const identityProjectDalFactory = (db: TDbClient) => { + const identityProjectOrm = ormify(db, TableName.IdentityProjectMembership); + return identityProjectOrm; +}; diff --git a/backend-pg/src/services/identity-project/identity-project-service.ts b/backend-pg/src/services/identity-project/identity-project-service.ts new file mode 100644 index 000000000..4effd7420 --- /dev/null +++ b/backend-pg/src/services/identity-project/identity-project-service.ts @@ -0,0 +1,186 @@ +import { ForbiddenError } from "@casl/ability"; + +import { ProjectMembershipRole, TProjectRoles } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { + ProjectPermissionActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; + +import { ActorType } from "../auth/auth-type"; +import { TIdentityOrgDalFactory } from "../identity/identity-org-dal"; +import { TProjectDalFactory } from "../project/project-dal"; +import { TIdentityProjectDalFactory } from "./identity-project-dal"; +import { + TCreateProjectIdentityDTO, + TDeleteProjectIdentityDTO, + TListProjectIdentityDTO, + TUpdateProjectIdentityDTO +} from "./identity-project-types"; + +type TIdentityProjectServiceFactoryDep = { + identityProjectDal: TIdentityProjectDalFactory; + projectDal: Pick; + identityOrgMembershipDal: Pick; + permissionService: Pick< + TPermissionServiceFactory, + "getProjectPermission" | "getProjectPermissionByRole" + >; +}; + +export type TIdentityProjectServiceFactory = ReturnType; + +export const identityProjectServiceFactory = ({ + identityProjectDal, + permissionService, + identityOrgMembershipDal, + projectDal +}: TIdentityProjectServiceFactoryDep) => { + const createProjectIdentity = async ({ + identityId, + actor, + actorId, + projectId, + role + }: TCreateProjectIdentityDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.Identity + ); + + const existingIdentity = await identityProjectDal.findOne({ identityId, projectId }); + if (existingIdentity) + throw new BadRequestError({ + message: `Identity with id ${identityId} already exists in project with id ${projectId}` + }); + + const project = await projectDal.findById(projectId); + const identityOrgMembership = await identityOrgMembershipDal.findOne({ + identityId, + orgId: project.orgId + }); + if (!identityOrgMembership) + throw new BadRequestError({ + message: `Failed to find identity with id ${identityId}` + }); + + const { permission: rolePermission, role: customRole } = + await permissionService.getProjectPermissionByRole(role, project.id); + const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission); + if (!hasPriviledge) + throw new ForbiddenRequestError({ + message: "Failed to add identity to project with more privileged role" + }); + const isCustomRole = Boolean(customRole); + + const projectIdentity = await identityProjectDal.create({ + identityId, + projectId: project.id, + role: isCustomRole ? ProjectMembershipRole.Custom : role, + roleId: customRole?.id + }); + return projectIdentity; + }; + + const updateProjectIdentity = async ({ + projectId, + identityId, + role, + actor, + actorId + }: TUpdateProjectIdentityDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Identity + ); + + const projectIdentity = await identityProjectDal.findOne({ identityId, projectId }); + if (!projectIdentity) + throw new BadRequestError({ + message: `Identity with id ${identityId} doesn't exists in project with id ${projectId}` + }); + + const { permission: identityRolePermission } = await permissionService.getProjectPermission( + ActorType.IDENTITY, + projectIdentity.identityId, + projectIdentity.projectId + ); + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); + if (!hasRequiredPriviledges) + throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); + + let customRole: TProjectRoles | undefined; + if (role) { + const { permission: rolePermission, role: customOrgRole } = + await permissionService.getProjectPermissionByRole(role, projectIdentity.projectId); + + const isCustomRole = Boolean(customOrgRole); + const hasRequiredNewRolePermission = isAtLeastAsPrivileged(permission, rolePermission); + if (!hasRequiredNewRolePermission) + throw new BadRequestError({ message: "Failed to create a more privileged identity" }); + if (isCustomRole) customRole = customOrgRole; + } + + const [updatedProjectIdentity] = await identityProjectDal.update( + { projectId, identityId: projectIdentity.identityId }, + { + role: customRole ? ProjectMembershipRole.Custom : role, + roleId: customRole ? customRole.id : null + } + ); + return updatedProjectIdentity; + }; + + const deleteProjectIdentity = async ({ + identityId, + actorId, + actor + }: TDeleteProjectIdentityDTO) => { + const identityProjectMembership = await identityProjectDal.findById(identityId); + if (!identityProjectMembership) + throw new BadRequestError({ message: `Failed to find identity with id ${identityId}` }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + identityProjectMembership.projectId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.Identity + ); + const { permission: identityRolePermission } = await permissionService.getProjectPermission( + ActorType.IDENTITY, + identityId, + identityProjectMembership.projectId + ); + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); + if (!hasRequiredPriviledges) + throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); + + const [deletedIdentity] = await identityProjectDal.delete({ identityId }); + return deletedIdentity; + }; + + const listProjectIdentities = async ({ projectId, actor, actorId }: TListProjectIdentityDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Identity + ); + + const identityMemberhips = await identityProjectDal.find({ projectId }); + return identityMemberhips; + }; + + return { + createProjectIdentity, + updateProjectIdentity, + deleteProjectIdentity, + listProjectIdentities + }; +}; diff --git a/backend-pg/src/services/identity-project/identity-project-types.ts b/backend-pg/src/services/identity-project/identity-project-types.ts new file mode 100644 index 000000000..5e9e972c1 --- /dev/null +++ b/backend-pg/src/services/identity-project/identity-project-types.ts @@ -0,0 +1,17 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TCreateProjectIdentityDTO = { + identityId: string; + role: string; +} & TProjectPermission; + +export type TUpdateProjectIdentityDTO = { + role: string; + identityId: string; +} & TProjectPermission; + +export type TDeleteProjectIdentityDTO = { + identityId: string; +} & Omit; + +export type TListProjectIdentityDTO = TProjectPermission; diff --git a/backend-pg/src/services/identity/identity-dal.ts b/backend-pg/src/services/identity/identity-dal.ts new file mode 100644 index 000000000..9bdccbd3d --- /dev/null +++ b/backend-pg/src/services/identity/identity-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TIdentityDalFactory = ReturnType; + +export const identityDalFactory = (db: TDbClient) => { + const identityOrm = ormify(db, TableName.Identity); + return identityOrm; +}; diff --git a/backend-pg/src/services/identity/identity-org-dal.ts b/backend-pg/src/services/identity/identity-org-dal.ts new file mode 100644 index 000000000..b4e63f29a --- /dev/null +++ b/backend-pg/src/services/identity/identity-org-dal.ts @@ -0,0 +1,35 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName,TIdentityOrgMemberships } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TIdentityOrgDalFactory = ReturnType; + +export const identityOrgDalFactory = (db: TDbClient) => { + const identityOrgOrm = ormify(db, TableName.IdentityOrgMembership); + + const findOne = async (filter: Partial, tx?: Knex) => { + try { + const [data] = await (tx || db)(TableName.IdentityOrgMembership) + .where(filter) + .join( + TableName.Identity, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.Identity}.id` + ) + .select(selectAllTableCols(TableName.IdentityOrgMembership)) + .select(db.ref("name").withSchema(TableName.Identity)) + .select(db.ref("authMethod").withSchema(TableName.Identity)); + if (data) { + const { name, authMethod } = data; + return { ...data, identity: { id: data.identityId, name, authMethod } }; + } + } catch (error) { + throw new DatabaseError({ error, name: "FindOne" }); + } + }; + + return { ...identityOrgOrm, findOne }; +}; diff --git a/backend-pg/src/services/identity/identity-service.ts b/backend-pg/src/services/identity/identity-service.ts new file mode 100644 index 000000000..0139d8f49 --- /dev/null +++ b/backend-pg/src/services/identity/identity-service.ts @@ -0,0 +1,149 @@ +import { ForbiddenError } from "@casl/ability"; + +import { OrgMembershipRole, TOrgRoles } from "@app/db/schemas"; +import { + OrgPermissionActions, + OrgPermissionSubjects +} from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; + +import { ActorType } from "../auth/auth-type"; +import { TIdentityDalFactory } from "./identity-dal"; +import { TIdentityOrgDalFactory } from "./identity-org-dal"; +import { TCreateIdentityDTO, TDeleteIdentityDTO, TUpdateIdentityDTO } from "./identity-types"; + +type TIdentityServiceFactoryDep = { + identityDal: TIdentityDalFactory; + identityOrgDal: TIdentityOrgDalFactory; + permissionService: Pick; +}; + +export type TIdentityServiceFactory = ReturnType; + +export const identityServiceFactory = ({ + identityDal, + identityOrgDal, + permissionService +}: TIdentityServiceFactoryDep) => { + const createIdentity = async ({ name, role, actor, orgId, actorId }: TCreateIdentityDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Create, + OrgPermissionSubjects.Identity + ); + + const { permission: rolePermission, role: customRole } = + await permissionService.getOrgPermissionByRole(role, orgId); + const isCustomRole = Boolean(customRole); + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, rolePermission); + if (!hasRequiredPriviledges) + throw new BadRequestError({ message: "Failed to create a more privileged identity" }); + + const identity = await identityDal.transaction(async (tx) => { + const newIdentity = await identityDal.create({ name }, tx); + await identityOrgDal.create( + { + identityId: newIdentity.id, + orgId, + role: isCustomRole ? OrgMembershipRole.Custom : role, + roleId: customRole?.id + }, + tx + ); + return newIdentity; + }); + + // TODO(akhilmhdh-pg): add audit log here + return identity; + }; + + const updateIdentity = async ({ id, role, name, actor, actorId }: TUpdateIdentityDTO) => { + const identityOrgMembership = await identityOrgDal.findById(id); + if (!identityOrgMembership) + throw new BadRequestError({ message: `Failed to find identity with id ${id}` }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityOrgMembership.orgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Edit, + OrgPermissionSubjects.Identity + ); + + const { permission: identityRolePermission } = await permissionService.getOrgPermission( + ActorType.IDENTITY, + id, + identityOrgMembership.orgId + ); + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); + if (!hasRequiredPriviledges) + throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); + + let customRole: TOrgRoles | undefined; + if (role) { + const { permission: rolePermission, role: customOrgRole } = + await permissionService.getOrgPermissionByRole(role, identityOrgMembership.orgId); + + const isCustomRole = Boolean(customOrgRole); + const hasRequiredNewRolePermission = isAtLeastAsPrivileged(permission, rolePermission); + if (!hasRequiredNewRolePermission) + throw new BadRequestError({ message: "Failed to create a more privileged identity" }); + if (isCustomRole) customRole = customOrgRole; + } + + const identity = await identityDal.transaction(async (tx) => { + const newIdentity = await identityDal.updateById(id, { name }, tx); + if (role) { + await identityOrgDal.update( + { identityId: id }, + { + role: customRole ? OrgMembershipRole.Custom : role, + roleId: customRole?.id + }, + tx + ); + } + return newIdentity; + }); + + // TODO(akhilmhdh-pg): add audit log here + return identity; + }; + + const deleteIdentity = async ({ actorId, actor, id }: TDeleteIdentityDTO) => { + const identityOrgMembership = await identityOrgDal.findById(id); + if (!identityOrgMembership) + throw new BadRequestError({ message: `Failed to find identity with id ${id}` }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityOrgMembership.orgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Delete, + OrgPermissionSubjects.Identity + ); + const { permission: identityRolePermission } = await permissionService.getOrgPermission( + ActorType.IDENTITY, + id, + identityOrgMembership.orgId + ); + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); + if (!hasRequiredPriviledges) + throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); + + const deletedIdentity = await identityDal.deleteById(id); + return deletedIdentity; + }; + + return { + createIdentity, + updateIdentity, + deleteIdentity + }; +}; diff --git a/backend-pg/src/services/identity/identity-types.ts b/backend-pg/src/services/identity/identity-types.ts new file mode 100644 index 000000000..433979061 --- /dev/null +++ b/backend-pg/src/services/identity/identity-types.ts @@ -0,0 +1,16 @@ +import { TOrgPermission } from "@app/lib/types"; + +export type TCreateIdentityDTO = { + role: string; + name: string; +} & TOrgPermission; + +export type TUpdateIdentityDTO = { + id: string; + role: string; + name: string; +} & Omit; + +export type TDeleteIdentityDTO = { + id: string; +} & Omit; diff --git a/backend-pg/src/services/universal-auth/ua-client-secret-dal.ts b/backend-pg/src/services/universal-auth/ua-client-secret-dal.ts new file mode 100644 index 000000000..ac4c220c3 --- /dev/null +++ b/backend-pg/src/services/universal-auth/ua-client-secret-dal.ts @@ -0,0 +1,27 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +export type TUaClientSecretDalFactory = ReturnType; + +export const uaClientSecretDalFactory = (db: TDbClient) => { + const uaClientSecretOrm = ormify(db, TableName.IdentityUaClientSecret); + + const incrementUsage = async (id: string, tx?: Knex) => { + try { + const [doc] = await (tx || db)(TableName.IdentityUaClientSecret) + .where({ id }) + .update({ clientSecretLastUsedAt: new Date() }) + .increment("clientSecretNumUses", 1) + .returning("*"); + return doc; + } catch (error) { + throw new DatabaseError({ error, name: "IncrementUsage" }); + } + }; + + return { ...uaClientSecretOrm, incrementUsage }; +}; diff --git a/backend-pg/src/services/universal-auth/universal-auth-dal.ts b/backend-pg/src/services/universal-auth/universal-auth-dal.ts new file mode 100644 index 000000000..59044de24 --- /dev/null +++ b/backend-pg/src/services/universal-auth/universal-auth-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TUniversalAuthDalFactory = ReturnType; + +export const universalAuthDalFactory = (db: TDbClient) => { + const universalAuthOrm = ormify(db, TableName.IdentityUniversalAuth); + + return universalAuthOrm; +}; diff --git a/backend-pg/src/services/universal-auth/universal-auth-service.ts b/backend-pg/src/services/universal-auth/universal-auth-service.ts new file mode 100644 index 000000000..a41c96a74 --- /dev/null +++ b/backend-pg/src/services/universal-auth/universal-auth-service.ts @@ -0,0 +1,463 @@ +import crypto from "node:crypto"; + +import { ForbiddenError } from "@casl/ability"; +import bcrypt from "bcrypt"; +import jwt from "jsonwebtoken"; + +import { IdentityAuthMethod } from "@app/db/schemas"; +import { + OrgPermissionActions, + OrgPermissionSubjects +} from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors"; +import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; + +import { ActorType, AuthTokenType } from "../auth/auth-type"; +import { TIdentityDalFactory } from "../identity/identity-dal"; +import { TIdentityOrgDalFactory } from "../identity/identity-org-dal"; +import { TIdentityAccessTokenDalFactory } from "../identity-access-token/identity-access-token-dal"; +import { TUaClientSecretDalFactory } from "./ua-client-secret-dal"; +import { TUniversalAuthDalFactory } from "./universal-auth-dal"; +import { + TAttachUaDTO, + TCreateUaClientSecretDTO, + TGetUaClientSecretsDTO, + TGetUaDTO, + TRevokeUaClientSecretDTO, + TUpdateUaDTO +} from "./universal-auth-types"; + +type TUniversalAuthServiceFactoryDep = { + universalAuthDal: TUniversalAuthDalFactory; + uaClientSecretDal: TUaClientSecretDalFactory; + identityAccessTokenDal: TIdentityAccessTokenDalFactory; + identityOrgDal: TIdentityOrgDalFactory; + identityDal: Pick; + permissionService: Pick; +}; + +export type TUniversalAuthServiceFactory = ReturnType; + +export const universalAuthServiceFactory = ({ + universalAuthDal, + uaClientSecretDal, + identityAccessTokenDal, + identityOrgDal, + identityDal, + permissionService +}: TUniversalAuthServiceFactoryDep) => { + const login = async (clientId: string, clientSecret: string) => { + const identityUa = await universalAuthDal.findOne({ clientId }); + if (!identityUa) throw new UnauthorizedError(); + + // TODO(akhilmhdh-pg): add ip checking + const clientSecrtInfo = await uaClientSecretDal.find({ + identityUAId: identityUa.id, + isClientSecretRevoked: false + }); + + const validClientSecretInfo = clientSecrtInfo.find(({ clientSecretHash }) => + bcrypt.compareSync(clientSecret, clientSecretHash) + ); + if (!validClientSecretInfo) throw new UnauthorizedError(); + + const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } = + validClientSecretInfo; + if (clientSecretTTL > 0) { + const clientSecretCreated = new Date(validClientSecretInfo.createdAt); + const ttlInMilliseconds = clientSecretTTL * 1000; + const currentDate = new Date(); + const expirationTime = new Date(clientSecretCreated.getTime() + ttlInMilliseconds); + + if (currentDate > expirationTime) { + await uaClientSecretDal.updateById(validClientSecretInfo.id, { + isClientSecretRevoked: true + }); + + throw new UnauthorizedError({ + message: "Failed to authenticate identity credentials due to expired client secret" + }); + } + } + + if (clientSecretNumUsesLimit > 0 && clientSecretNumUses === clientSecretNumUsesLimit) { + // number of times client secret can be used for + // a login operation reached + await uaClientSecretDal.updateById(validClientSecretInfo.id, { isClientSecretRevoked: true }); + throw new UnauthorizedError({ + message: + "Failed to authenticate identity credentials due to client secret number of uses limit reached" + }); + } + + const identityAccessToken = await universalAuthDal.transaction(async (tx) => { + const uaClientSecretDoc = await uaClientSecretDal.incrementUsage( + validClientSecretInfo.id, + tx + ); + const newToken = await identityAccessTokenDal.create( + { + identityId: identityUa.identityId, + authType: IdentityAuthMethod.Univeral, + isAccessTokenRevoked: false, + identityUAClientSecretId: uaClientSecretDoc.id, + accessTokenTTL: identityUa.accessTokenTTL, + accessTokenMaxTTL: identityUa.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityUa.accessTokenNumUsesLimit + }, + tx + ); + return newToken; + }); + + const appCfg = getConfig(); + const accessToken = jwt.sign( + { + identityId: identityUa.identityId, + clientSecretId: validClientSecretInfo.id, + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + }, + appCfg.JWT_AUTH_SECRET, + { + expiresIn: + identityAccessToken.accessTokenMaxTTL === 0 + ? undefined + : identityAccessToken.accessTokenMaxTTL + } + ); + return { accessToken, identityUa }; + }; + + const attachUa = async ({ + accessTokenMaxTTL, + identityId, + accessTokenNumUsesLimit, + accessTokenTTL, + accessTokenTrustedIps, + clientSecretTrustedIps, + actorId, + actor + }: TAttachUaDTO) => { + const identityMembershipOrg = await identityOrgDal.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity.authMethod) + throw new BadRequestError({ + message: "Failed to add universal auth to already configured identity" + }); + + if (accessTokenMaxTTL > 0 && accessTokenTTL > accessTokenMaxTTL) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Create, + OrgPermissionSubjects.Identity + ); + const reformattedClientSecretTrustedIps = clientSecretTrustedIps.map( + (clientSecretTrustedIp) => { + // TODO(akhilmhdh-pg): add licence server here + if (/* !plan.ipAllowlisting && */ clientSecretTrustedIp.ipAddress !== "0.0.0.0/0") + throw new BadRequestError({ + message: + "Failed to add IP access range to service token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(clientSecretTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(clientSecretTrustedIp.ipAddress); + } + ); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { + // TODO(akhilmhdh-pg): add licence server here + if (/* !plan.ipAllowlisting && */ accessTokenTrustedIp.ipAddress !== "0.0.0.0/0") + throw new BadRequestError({ + message: + "Failed to add IP access range to service token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const identityUa = await universalAuthDal.transaction(async (tx) => { + const doc = await universalAuthDal.create( + { + identityId: identityMembershipOrg.identityId, + clientId: crypto.randomUUID(), + clientSecretTrustedIps: JSON.stringify(reformattedClientSecretTrustedIps), + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps) + }, + tx + ); + await identityDal.updateById( + identityMembershipOrg.identityId, + { + authMethod: IdentityAuthMethod.Univeral + }, + tx + ); + return doc; + }); + return identityUa; + }; + + const updateUa = async ({ + accessTokenMaxTTL, + identityId, + accessTokenNumUsesLimit, + accessTokenTTL, + accessTokenTrustedIps, + clientSecretTrustedIps, + actorId, + actor + }: TUpdateUaDTO) => { + const identityMembershipOrg = await identityOrgDal.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.Univeral) + throw new BadRequestError({ + message: "Failed to updated universal auth" + }); + + const uaIdentityAuth = await universalAuthDal.findOne({ identityId }); + + if ( + (accessTokenMaxTTL || uaIdentityAuth.accessTokenMaxTTL) > 0 && + (accessTokenTTL || uaIdentityAuth.accessTokenMaxTTL) > + (accessTokenMaxTTL || uaIdentityAuth.accessTokenMaxTTL) + ) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Edit, + OrgPermissionSubjects.Identity + ); + const reformattedClientSecretTrustedIps = clientSecretTrustedIps?.map( + (clientSecretTrustedIp) => { + // TODO(akhilmhdh-pg): add licence server here + if (/* !plan.ipAllowlisting && */ clientSecretTrustedIp.ipAddress !== "0.0.0.0/0") + throw new BadRequestError({ + message: + "Failed to add IP access range to service token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(clientSecretTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(clientSecretTrustedIp.ipAddress); + } + ); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { + // TODO(akhilmhdh-pg): add licence server here + if (/* !plan.ipAllowlisting && */ accessTokenTrustedIp.ipAddress !== "0.0.0.0/0") + throw new BadRequestError({ + message: + "Failed to add IP access range to service token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const updatedUaAuth = await universalAuthDal.updateById(uaIdentityAuth.id, { + clientSecretTrustedIps: reformattedClientSecretTrustedIps + ? JSON.stringify(reformattedClientSecretTrustedIps) + : undefined, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps + ? JSON.stringify(reformattedAccessTokenTrustedIps) + : undefined + }); + return updatedUaAuth; + }; + + const getIdentityUa = async ({ identityId, actorId, actor }: TGetUaDTO) => { + const identityMembershipOrg = await identityOrgDal.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.Univeral) + throw new BadRequestError({ + message: "The identity does not have universal auth" + }); + + const uaIdentityAuth = await universalAuthDal.findOne({ identityId }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Identity + ); + return uaIdentityAuth; + }; + + const createUaClientSecret = async ({ + actor, + actorId, + identityId, + ttl, + description, + numUsesLimit + }: TCreateUaClientSecretDTO) => { + const identityMembershipOrg = await identityOrgDal.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.Univeral) + throw new BadRequestError({ + message: "The identity does not have universal auth" + }); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Create, + OrgPermissionSubjects.Identity + ); + + const { permission: rolePermission } = await permissionService.getOrgPermission( + ActorType.IDENTITY, + identityMembershipOrg.identityId, + identityMembershipOrg.orgId + ); + const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission); + if (!hasPriviledge) + throw new ForbiddenRequestError({ + message: "Failed to add identity to project with more privileged role" + }); + + const appCfg = getConfig(); + const clientSecret = crypto.randomBytes(32).toString("hex"); + const clientSecretHash = await bcrypt.hash(clientSecret, appCfg.SALT_ROUNDS); + const identityUniversalAuth = await universalAuthDal.findOne({ + identityId + }); + + const identityUaClientSecret = await uaClientSecretDal.create({ + identityUAId: identityUniversalAuth.id, + description, + clientSecretPrefix: clientSecret.slice(0, 4), + clientSecretHash, + clientSecretNumUses: 0, + clientSecretNumUsesLimit: numUsesLimit, + clientSecretTTL: ttl, + isClientSecretRevoked: false + }); + return { clientSecret: identityUaClientSecret, uaAuth: identityUniversalAuth }; + }; + + const getUaClientSecrets = async ({ actor, actorId, identityId }: TGetUaClientSecretsDTO) => { + const identityMembershipOrg = await identityOrgDal.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.Univeral) + throw new BadRequestError({ + message: "The identity does not have universal auth" + }); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Identity + ); + + const { permission: rolePermission } = await permissionService.getOrgPermission( + ActorType.IDENTITY, + identityMembershipOrg.identityId, + identityMembershipOrg.orgId + ); + const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission); + if (!hasPriviledge) + throw new ForbiddenRequestError({ + message: "Failed to add identity to project with more privileged role" + }); + + const identityUniversalAuth = await universalAuthDal.findOne({ + identityId + }); + + const clientSecrets = await uaClientSecretDal.findOne({ + identityUAId: identityUniversalAuth.id, + isClientSecretRevoked: false + }); + return clientSecrets; + }; + + const revokeUaClientSecret = async ({ + identityId, + actorId, + actor, + clientSecretId + }: TRevokeUaClientSecretDTO) => { + const identityMembershipOrg = await identityOrgDal.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.Univeral) + throw new BadRequestError({ + message: "The identity does not have universal auth" + }); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Delete, + OrgPermissionSubjects.Identity + ); + + const { permission: rolePermission } = await permissionService.getOrgPermission( + ActorType.IDENTITY, + identityMembershipOrg.identityId, + identityMembershipOrg.orgId + ); + const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission); + if (!hasPriviledge) + throw new ForbiddenRequestError({ + message: "Failed to add identity to project with more privileged role" + }); + + const clientSecret = await uaClientSecretDal.updateById(clientSecretId, { + isClientSecretRevoked: true + }); + return clientSecret; + }; + + return { + login, + attachUa, + updateUa, + getIdentityUa, + createUaClientSecret, + getUaClientSecrets, + revokeUaClientSecret + }; +}; diff --git a/backend-pg/src/services/universal-auth/universal-auth-types.ts b/backend-pg/src/services/universal-auth/universal-auth-types.ts new file mode 100644 index 000000000..5f812cbcf --- /dev/null +++ b/backend-pg/src/services/universal-auth/universal-auth-types.ts @@ -0,0 +1,39 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TAttachUaDTO = { + identityId: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + clientSecretTrustedIps: { ipAddress: string }[]; + accessTokenTrustedIps: { ipAddress: string }[]; +} & Omit; + +export type TUpdateUaDTO = { + identityId: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + clientSecretTrustedIps?: { ipAddress: string }[]; + accessTokenTrustedIps?: { ipAddress: string }[]; +} & Omit; + +export type TGetUaDTO = { + identityId: string; +} & Omit; + +export type TCreateUaClientSecretDTO = { + identityId: string; + description?: string; + numUsesLimit: number; + ttl: number; +} & Omit; + +export type TGetUaClientSecretsDTO = { + identityId: string; +} & Omit; + +export type TRevokeUaClientSecretDTO = { + identityId: string; + clientSecretId: string; +} & Omit;