From ef6282968b7f59c23354943a3b3d589c90fcf6de Mon Sep 17 00:00:00 2001 From: = Date: Sat, 18 Oct 2025 15:30:21 +0530 Subject: [PATCH] feat: first iteration changes for sub org routers --- backend/src/@types/fastify.d.ts | 3 + .../db/migrations/20251018061215_sub-org.ts | 36 ++++++ backend/src/db/schemas/identities.ts | 3 +- backend/src/db/schemas/models.ts | 6 + backend/src/db/schemas/organizations.ts | 3 +- backend/src/ee/routes/v1/sub-org-router.ts | 114 ++++++++++++++++++ .../ee/services/audit-log/audit-log-types.ts | 11 ++ .../src/ee/services/license/license-fns.ts | 1 + .../src/ee/services/license/license-types.ts | 1 + .../ee/services/permission/org-permission.ts | 19 ++- .../ee/services/permission/permission-dal.ts | 5 +- .../permission/permission-service-types.ts | 26 +--- .../services/permission/permission-service.ts | 30 +++-- .../ee/services/sub-org/sub-org-service.ts | 109 +++++++++++++++++ .../src/ee/services/sub-org/sub-org-types.ts | 16 +++ backend/src/lib/api-docs/constants.ts | 12 ++ backend/src/lib/types/index.ts | 1 + .../server/plugins/auth/inject-identity.ts | 48 +++++--- .../server/plugins/auth/inject-permission.ts | 8 +- .../services/auth-token/auth-token-service.ts | 61 +++++++--- .../identity-access-token-dal.ts | 1 + .../identity-access-token-service.ts | 48 ++++++-- backend/src/services/org/org-dal.ts | 44 +++++++ 23 files changed, 529 insertions(+), 77 deletions(-) create mode 100644 backend/src/db/migrations/20251018061215_sub-org.ts create mode 100644 backend/src/ee/routes/v1/sub-org-router.ts create mode 100644 backend/src/ee/services/sub-org/sub-org-service.ts create mode 100644 backend/src/ee/services/sub-org/sub-org-types.ts diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index e3e8733f0..59ca1cf55 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -126,6 +126,7 @@ import { TUserServiceFactory } from "@app/services/user/user-service"; import { TUserEngagementServiceFactory } from "@app/services/user-engagement/user-engagement-service"; import { TWebhookServiceFactory } from "@app/services/webhook/webhook-service"; import { TWorkflowIntegrationServiceFactory } from "@app/services/workflow-integration/workflow-integration-service"; +import { TSubOrgServiceFactory } from "@app/ee/services/sub-org/sub-org-service"; declare module "@fastify/request-context" { interface RequestContextData { @@ -178,6 +179,7 @@ declare module "fastify" { type: ActorType; id: string; orgId: string; + parentOrgId: string; }; rateLimits: RateLimitConfiguration; // passport data @@ -327,6 +329,7 @@ declare module "fastify" { additionalPrivilege: TAdditionalPrivilegeServiceFactory; role: TRoleServiceFactory; convertor: TConvertorServiceFactory; + subOrganization: TSubOrgServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/db/migrations/20251018061215_sub-org.ts b/backend/src/db/migrations/20251018061215_sub-org.ts new file mode 100644 index 000000000..3824bf68c --- /dev/null +++ b/backend/src/db/migrations/20251018061215_sub-org.ts @@ -0,0 +1,36 @@ +import { Knex } from "knex"; +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasParentOrgId = await knex.schema.hasColumn(TableName.Organization, "parentOrgId"); + if (!hasParentOrgId) { + await knex.schema.alterTable(TableName.Organization, (t) => { + t.uuid("parentOrgId"); + t.foreign("parentOrgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + }); + } + + const hasIdentityOrgCol = await knex.schema.hasColumn(TableName.Identity, "orgId"); + if (!hasIdentityOrgCol) { + await knex.schema.alterTable(TableName.Identity, (t) => { + t.uuid("orgId").notNullable(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasParentOrgId = await knex.schema.hasColumn(TableName.Organization, "parentOrgId"); + if (hasParentOrgId) { + await knex.schema.alterTable(TableName.Organization, (t) => { + t.dropColumn("parentOrgId"); + }); + } + + const hasIdentityOrgCol = await knex.schema.hasColumn(TableName.Identity, "orgId"); + if (hasIdentityOrgCol) { + await knex.schema.alterTable(TableName.Identity, (t) => { + t.dropColumn("orgId"); + }); + } +} diff --git a/backend/src/db/schemas/identities.ts b/backend/src/db/schemas/identities.ts index a592e2480..06c37ff22 100644 --- a/backend/src/db/schemas/identities.ts +++ b/backend/src/db/schemas/identities.ts @@ -13,7 +13,8 @@ export const IdentitiesSchema = z.object({ authMethod: z.string().nullable().optional(), createdAt: z.date(), updatedAt: z.date(), - hasDeleteProtection: z.boolean().default(false) + hasDeleteProtection: z.boolean().default(false), + orgId: z.string().uuid() }); export type TIdentities = z.infer; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index f7291a70f..4705df2ce 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -312,6 +312,12 @@ export enum ActionProjectType { Any = "any" } +export enum OrganizationActionScope { + ChildOrganization = "child-organization-only", + ParentOrganization = "parent-organization-only", + Any = "any" +} + export enum TemporaryPermissionMode { Relative = "relative" } diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index afc9e2b73..38c6f797d 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -38,7 +38,8 @@ export const OrganizationsSchema = z.object({ maxSharedSecretLifetime: z.number().default(2592000).nullable().optional(), maxSharedSecretViewLimit: z.number().nullable().optional(), googleSsoAuthEnforced: z.boolean().default(false), - googleSsoAuthLastUsed: z.date().nullable().optional() + googleSsoAuthLastUsed: z.date().nullable().optional(), + parentOrgId: z.string().uuid().nullable().optional() }); export type TOrganizations = z.infer; diff --git a/backend/src/ee/routes/v1/sub-org-router.ts b/backend/src/ee/routes/v1/sub-org-router.ts new file mode 100644 index 000000000..9b9ecfea2 --- /dev/null +++ b/backend/src/ee/routes/v1/sub-org-router.ts @@ -0,0 +1,114 @@ +import { z } from "zod"; + +import { OrganizationsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags, SUB_ORGANIZATIONS } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerSubOrgRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SubOrganizations], + description: "Create a child organization", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + name: z.string().trim().describe(SUB_ORGANIZATIONS.CREATE.name) + }), + response: { + 200: z.object({ + organization: OrganizationsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { organization } = await server.services.subOrganization.createSubOrg({ + name: req.body.name, + permissionActor: { + id: req.permission.id, + type: req.permission.type, + authMethod: req.permission.authMethod, + orgId: req.permission.orgId, + parentOrgId: req.permission.parentOrgId + } + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.CREATE_CHILD_ORGANIZATION, + metadata: { + name: req.body.name, + organizationId: organization.id + } + } + }); + + return { organization }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SubOrganizations], + description: "List child organizations", + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + limit: z.coerce.number().min(1).max(100).default(25).describe(SUB_ORGANIZATIONS.LIST.limit), + offset: z.coerce.number().min(0).default(0).describe(SUB_ORGANIZATIONS.LIST.offset), + isAccessible: z + .enum(["true", "false"]) + .optional() + .transform((value) => value === "true") + .describe(SUB_ORGANIZATIONS.LIST.isAccessible) + }), + response: { + 200: z.object({ + organizations: OrganizationsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { organizations } = await server.services.subOrganization.listSubOrgs({ + permissionActor: { + id: req.permission.id, + type: req.permission.type, + authMethod: req.permission.authMethod, + orgId: req.permission.orgId, + parentOrgId: req.permission.orgId + }, + data: { + limit: req.query.limit, + offset: req.query.offset, + isAccessible: req.query.isAccessible + } + }); + + return { organizations }; + } + }); +}; diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index bc50283d4..b5d49b7e4 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -173,6 +173,8 @@ export enum EventType { UPDATE_TOKEN_IDENTITY_TOKEN_AUTH = "update-token-identity-token-auth", GET_TOKENS_IDENTITY_TOKEN_AUTH = "get-tokens-identity-token-auth", + CREATE_CHILD_ORGANIZATION = "create-child-organization", + ADD_IDENTITY_TOKEN_AUTH = "add-identity-token-auth", UPDATE_IDENTITY_TOKEN_AUTH = "update-identity-token-auth", GET_IDENTITY_TOKEN_AUTH = "get-identity-token-auth", @@ -607,6 +609,14 @@ interface GetSecretsEvent { }; } +interface CreateChildOrganizationEvent { + type: EventType.CREATE_CHILD_ORGANIZATION; + metadata: { + name: string; + organizationId: string; + }; +} + type TSecretMetadata = { key: string; value: string }[]; interface GetSecretEvent { @@ -3863,6 +3873,7 @@ interface PamResourceDeleteEvent { } export type Event = + | CreateChildOrganizationEvent | GetSecretsEvent | GetSecretEvent | CreateSecretEvent diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 2a3cf82cc..e5c775f07 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -28,6 +28,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ rbac: false, githubOrgSync: false, customRateLimits: false, + childOrganization: true, customAlerts: false, secretAccessInsights: false, auditLogs: false, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 9cdcfcc3d..8090d789e 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -33,6 +33,7 @@ export type TFeatureSet = { membersUsed: number; identityLimit: null; identitiesUsed: number; + childOrganization: true; environmentLimit: null; environmentsUsed: 0; secretVersioning: true; diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index d4da8c98f..45b867398 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -15,6 +15,11 @@ export enum OrgPermissionActions { Delete = "delete" } +export enum OrgPermissionChildOrgActions { + Create = "create", + DirectAccess = "direct-access" +} + export enum OrgPermissionAppConnectionActions { Read = "read", Create = "create", @@ -117,7 +122,8 @@ export enum OrgPermissionSubjects { Kmip = "kmip", Gateway = "gateway", Relay = "relay", - SecretShare = "secret-share" + SecretShare = "secret-share", + ChildOrganization = "child-organization" } export type AppConnectionSubjectFields = { @@ -128,6 +134,7 @@ export type OrgPermissionSet = | [OrgPermissionActions.Create, OrgPermissionSubjects.Workspace] | [OrgPermissionActions.Create, OrgPermissionSubjects.Project] | [OrgPermissionActions, OrgPermissionSubjects.Role] + | [OrgPermissionChildOrgActions, OrgPermissionSubjects.ChildOrganization] | [OrgPermissionActions, OrgPermissionSubjects.Member] | [OrgPermissionActions, OrgPermissionSubjects.Settings] | [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount] @@ -185,6 +192,12 @@ export const OrgPermissionSchema = z.discriminatedUnion("subject", [ subject: z.literal(OrgPermissionSubjects.Role).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") }), + z.object({ + subject: z.literal(OrgPermissionSubjects.ChildOrganization).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionChildOrgActions).describe( + "Describe what action an entity can take." + ) + }), z.object({ subject: z.literal(OrgPermissionSubjects.Member).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") @@ -308,6 +321,10 @@ const buildAdminPermission = () => { // ws permissions can(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace); can(OrgPermissionActions.Create, OrgPermissionSubjects.Project); + + can(OrgPermissionChildOrgActions.Create, OrgPermissionSubjects.ChildOrganization); + can(OrgPermissionChildOrgActions.DirectAccess, OrgPermissionSubjects.ChildOrganization); + // role permission can(OrgPermissionActions.Read, OrgPermissionSubjects.Role); can(OrgPermissionActions.Create, OrgPermissionSubjects.Role); diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index 49a375f8f..eb45158ab 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -19,6 +19,7 @@ interface TPermissionDataReturn extends TMemberships { orgAuthEnforced?: boolean | null; orgGoogleSsoAuthEnforced?: boolean | null; shouldUseNewPrivilegeSystem?: boolean | null; + parentOrgId?: boolean | null; bypassOrgAuthEnabled?: boolean | null; roles: { id: string; @@ -273,7 +274,8 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { db.ref("shouldUseNewPrivilegeSystem").withSchema(TableName.Organization), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), db.ref("googleSsoAuthEnforced").withSchema(TableName.Organization).as("orgGoogleSsoAuthEnforced"), - db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled") + db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"), + db.ref("parentOrgId").withSchema(TableName.Organization).as("parentOrgId") ); const data = sqlNestRelationships({ @@ -283,6 +285,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { MembershipsSchema.extend({ orgAuthEnforced: z.boolean().optional().nullable(), shouldUseNewPrivilegeSystem: z.boolean().optional().nullable(), + parentOrgId: z.boolean().optional().nullable(), orgGoogleSsoAuthEnforced: z.boolean(), bypassOrgAuthEnabled: z.boolean() }).parse(el), diff --git a/backend/src/ee/services/permission/permission-service-types.ts b/backend/src/ee/services/permission/permission-service-types.ts index 1f0e00470..34660d17d 100644 --- a/backend/src/ee/services/permission/permission-service-types.ts +++ b/backend/src/ee/services/permission/permission-service-types.ts @@ -2,7 +2,7 @@ import { MongoAbility } from "@casl/ability"; import { MongoQuery } from "@ucast/mongo2js"; import { Knex } from "knex"; -import { ActionProjectType, TMemberships } from "@app/db/schemas"; +import { ActionProjectType, OrganizationActionScope, TMemberships } from "@app/db/schemas"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { OrgPermissionSet } from "./org-permission"; @@ -18,21 +18,6 @@ export type TBuildOrgPermissionDTO = { role: string; }[]; -export type TGetUserProjectPermissionArg = { - userId: string; - projectId: string; - authMethod: ActorAuthMethod; - actionProjectType: ActionProjectType; - userOrgId?: string; -}; - -export type TGetIdentityProjectPermissionArg = { - identityId: string; - projectId: string; - identityOrgId?: string; - actionProjectType: ActionProjectType; -}; - export type TGetServiceTokenProjectPermissionArg = { serviceTokenId: string; projectId: string; @@ -55,16 +40,11 @@ export type TGetOrgPermissionArg = { orgId: string; actorAuthMethod: ActorAuthMethod; actorOrgId?: string; + scope: OrganizationActionScope; }; export type TPermissionServiceFactory = { - getOrgPermission: ( - type: ActorType, - id: string, - orgId: string, - authMethod: ActorAuthMethod, - actorOrgId: string | undefined - ) => Promise<{ + getOrgPermission: (arg: TGetOrgPermissionArg) => Promise<{ permission: MongoAbility; memberships: Array< TMemberships & { diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index 2d879b4fd..dc4874b10 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -7,6 +7,7 @@ import { Knex } from "knex"; import { AccessScope, ActionProjectType, + OrganizationActionScope, OrgMembershipRole, ProjectMembershipRole, ServiceTokenScopes @@ -179,14 +180,15 @@ export const permissionServiceFactory = ({ // return minTtl; // }; - const getOrgPermission: TPermissionServiceFactory["getOrgPermission"] = async ( - type, - id, + const getOrgPermission: TPermissionServiceFactory["getOrgPermission"] = async ({ + actor, + actorId, orgId, - authMethod, - actorOrgId - ) => { - if (type !== ActorType.USER && type !== ActorType.IDENTITY) { + actorOrgId, + scope, + actorAuthMethod + }) => { + if (actor !== ActorType.USER && actor !== ActorType.IDENTITY) { throw new BadRequestError({ message: "Invalid actor provided", name: "Get org permission" @@ -202,11 +204,19 @@ export const permissionServiceFactory = ({ scope: AccessScope.Organization, orgId }, - actorId: id, - actorType: type + actorId, + actorType: actor }); if (!permissionData?.length) throw new ForbiddenRequestError({ name: "You are not member of this organization" }); + const parentOrgId = permissionData?.[0]?.parentOrgId; + const isChild = Boolean(parentOrgId); + if (scope === OrganizationActionScope.ParentOrganization && isChild) { + throw new BadRequestError({ message: `Child organization cannot do this operation` }); + } else if (scope === OrganizationActionScope.ChildOrganization && !isChild) { + throw new BadRequestError({ message: `Parent organization cannot do this operation` }); + } + const permissionFromRoles = permissionData.flatMap((membership) => { const activeRoles = membership?.roles .filter( @@ -227,7 +237,7 @@ export const permissionServiceFactory = ({ permissionData.some((memberships) => memberships.roles.some((el) => role === (el.customRoleSlug || el.role))); validateOrgSSO( - authMethod, + actorAuthMethod, permissionData?.[0].orgAuthEnforced, Boolean(permissionData?.[0].orgGoogleSsoAuthEnforced), Boolean(permissionData?.[0].bypassOrgAuthEnabled), diff --git a/backend/src/ee/services/sub-org/sub-org-service.ts b/backend/src/ee/services/sub-org/sub-org-service.ts new file mode 100644 index 000000000..37c5545c0 --- /dev/null +++ b/backend/src/ee/services/sub-org/sub-org-service.ts @@ -0,0 +1,109 @@ +import { ForbiddenError } from "@casl/ability"; + +import { AccessScope, OrganizationActionScope, OrgMembershipRole, OrgMembershipStatus } from "@app/db/schemas"; +import { BadRequestError } from "@app/lib/errors"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TMembershipDALFactory } from "@app/services/membership/membership-dal"; +import { TMembershipRoleDALFactory } from "@app/services/membership/membership-role-dal"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; + +import { TLicenseServiceFactory } from "../license/license-service"; +import { OrgPermissionChildOrgActions, OrgPermissionSubjects } from "../permission/org-permission"; +import { TPermissionServiceFactory } from "../permission/permission-service-types"; +import { TCreateSubOrgDTO, TListSubOrgDTO } from "./sub-org-types"; + +type TSubOrgServiceFactoryDep = { + orgDAL: Pick; + permissionService: Pick; + licenseService: Pick; + membershipDAL: Pick; + membershipRoleDAL: Pick; +}; + +export type TSubOrgServiceFactory = ReturnType; + +export const subOrgServiceFactory = ({ + orgDAL, + permissionService, + licenseService, + membershipDAL, + membershipRoleDAL +}: TSubOrgServiceFactoryDep) => { + const createSubOrg = async ({ name, permissionActor }: TCreateSubOrgDTO) => { + const { permission } = await permissionService.getOrgPermission({ + actorId: permissionActor.id, + actor: permissionActor.type, + orgId: permissionActor.orgId, + actorOrgId: permissionActor.orgId, + actorAuthMethod: permissionActor.authMethod, + scope: OrganizationActionScope.ParentOrganization + }); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionChildOrgActions.Create, + OrgPermissionSubjects.ChildOrganization + ); + + const orgLicensePlan = await licenseService.getPlan(permissionActor.parentOrgId); + if (!orgLicensePlan.gateway) { + throw new BadRequestError({ + message: "Child organization creation failed. Please upgrade your instance to Infisical's Enterprise plan." + }); + } + + const organization = await orgDAL.transaction(async (tx) => { + const org = await orgDAL.create({ name, slug: name, parentOrgId: permissionActor.orgId }, tx); + const membership = await membershipDAL.create( + { + scope: AccessScope.Organization, + [permissionActor.type === ActorType.IDENTITY ? "actorIdentityId" : "actorUserId"]: permissionActor.id, + scopeOrgId: org.id, + status: OrgMembershipStatus.Accepted, + isActive: true + }, + tx + ); + await membershipRoleDAL.create( + { + membershipId: membership.id, + role: OrgMembershipRole.Admin + }, + tx + ); + return org; + }); + + return { + organization + }; + }; + + const listSubOrgs = async ({ permissionActor, data }: TListSubOrgDTO) => { + await permissionService.getOrgPermission({ + actorId: permissionActor.id, + actor: permissionActor.type, + orgId: permissionActor.parentOrgId, + actorOrgId: permissionActor.parentOrgId, + actorAuthMethod: permissionActor.authMethod, + scope: OrganizationActionScope.ParentOrganization + }); + + const organizations = await orgDAL.listSubOrganizations({ + actorId: permissionActor.id, + actorType: permissionActor.type, + orgId: permissionActor.parentOrgId, + isAccessible: data?.isAccessible, + limit: data?.limit, + offset: data?.offset + }); + + return { + organizations + }; + }; + + return { + createSubOrg, + listSubOrgs + }; +}; diff --git a/backend/src/ee/services/sub-org/sub-org-types.ts b/backend/src/ee/services/sub-org/sub-org-types.ts new file mode 100644 index 000000000..fc2a47b59 --- /dev/null +++ b/backend/src/ee/services/sub-org/sub-org-types.ts @@ -0,0 +1,16 @@ +import { OrgServiceActor } from "@app/lib/types"; + +export type TCreateSubOrgDTO = { + name: string; + permissionActor: OrgServiceActor; +}; + +export type TListSubOrgDTO = { + permissionActor: OrgServiceActor; + data: Partial<{ + limit?: number; + offset?: number; + search?: string; + isAccessible?: boolean; + }>; +}; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 8d1ae45bf..77ce47cd5 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -33,6 +33,7 @@ export enum ApiDocsTags { LdapAuth = "LDAP Auth", Groups = "Groups", Organizations = "Organizations", + SubOrganizations = "Sub Organizations", Projects = "Projects", ProjectUsers = "Project Users", ProjectGroups = "Project Groups", @@ -716,6 +717,17 @@ export const ORGANIZATIONS = { } } as const; +export const SUB_ORGANIZATIONS = { + CREATE: { + name: "The name of the child organization to create." + }, + LIST: { + limit: "The number of child organizations to return.", + offset: "The offset to start from. If you enter 10, it will start from the 10th child organization.", + isAccessible: "Filter to only return child organizations that the actor has access to." + } +} as const; + export const PROJECTS = { CREATE: { organizationSlug: "The slug of the organization to create the project in.", diff --git a/backend/src/lib/types/index.ts b/backend/src/lib/types/index.ts index a7a60349f..1a2262bc1 100644 --- a/backend/src/lib/types/index.ts +++ b/backend/src/lib/types/index.ts @@ -78,6 +78,7 @@ export type OrgServiceActor = { id: string; authMethod: ActorAuthMethod; orgId: string; + parentOrgId: string; }; export enum QueueWorkerProfile { diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index 1bff11879..f91d56f32 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -20,6 +20,7 @@ export type TAuthMode = tokenVersionId: string; // the session id of token used user: TUsers; orgId: string; + parentOrgId: string; authMethod: AuthMethod; isMfaVerified?: boolean; token: AuthModeJwtTokenPayload; @@ -31,6 +32,7 @@ export type TAuthMode = userId: string; user: TUsers; orgId: string; + parentOrgId: string; token: string; } | { @@ -39,6 +41,7 @@ export type TAuthMode = actor: ActorType.SERVICE; serviceTokenId: string; orgId: string; + parentOrgId: string; authMethod: null; token: string; } @@ -48,6 +51,7 @@ export type TAuthMode = identityId: string; identityName: string; orgId: string; + parentOrgId: string; authMethod: null; isInstanceAdmin?: boolean; token: TIdentityAccessTokenJwtPayload; @@ -57,6 +61,7 @@ export type TAuthMode = actor: ActorType.SCIM_CLIENT; scimTokenId: string; orgId: string; + parentOrgId: string; authMethod: null; }; @@ -136,17 +141,24 @@ export const injectIdentity = fp( if (!authMode) return; + const subOrganizationSelector = req.headers?.["x-infisical-org"] as string | undefined; + switch (authMode) { case AuthMode.JWT: { - const { user, tokenVersionId, orgId } = await server.services.authToken.fnValidateJwtIdentity(token); + const { user, tokenVersionId, orgId, parentOrgId } = await server.services.authToken.fnValidateJwtIdentity( + token, + subOrganizationSelector + ); requestContext.set("orgId", orgId); + req.auth = { authMode: AuthMode.JWT, user, userId: user.id, tokenVersionId, actor, - orgId: orgId as string, + orgId, + parentOrgId, authMethod: token.authMethod, isMfaVerified: token.isMfaVerified, token @@ -154,13 +166,18 @@ export const injectIdentity = fp( break; } case AuthMode.IDENTITY_ACCESS_TOKEN: { - const identity = await server.services.identityAccessToken.fnValidateIdentityAccessToken(token, req.realIp); + const identity = await server.services.identityAccessToken.fnValidateIdentityAccessToken( + token, + subOrganizationSelector, + req.realIp + ); const serverCfg = await getServerCfg(); requestContext.set("orgId", identity.orgId); req.auth = { authMode: AuthMode.IDENTITY_ACCESS_TOKEN, actor, orgId: identity.orgId, + parentOrgId: identity.parentOrgId, identityId: identity.identityId, identityName: identity.name, authMethod: null, @@ -190,8 +207,13 @@ export const injectIdentity = fp( case AuthMode.SERVICE_TOKEN: { const serviceToken = await server.services.serviceToken.fnValidateServiceToken(token); requestContext.set("orgId", serviceToken.orgId); + + if (subOrganizationSelector) + throw new BadRequestError({ message: `Service token doesn't support sub organization selector` }); + req.auth = { orgId: serviceToken.orgId, + parentOrgId: serviceToken.orgId, authMode: AuthMode.SERVICE_TOKEN as const, serviceToken, serviceTokenId: serviceToken.id, @@ -202,22 +224,18 @@ export const injectIdentity = fp( break; } case AuthMode.API_KEY: { - const user = await server.services.apiKey.fnValidateApiKey(token as string); - req.auth = { - authMode: AuthMode.API_KEY as const, - userId: user.id, - actor, - user, - orgId: "API_KEY", // We set the orgId to an arbitrary value, since we can't link an API key to a specific org. We have to deprecate API keys soon! - authMethod: null, - token: token as string - }; - break; + throw new BadRequestError({ + message: "API key authentication is not supported anymore. Please switch to identity authentication." + }); } case AuthMode.SCIM_TOKEN: { const { orgId, scimTokenId } = await server.services.scim.fnValidateScimToken(token); requestContext.set("orgId", orgId); - req.auth = { authMode: AuthMode.SCIM_TOKEN, actor, scimTokenId, orgId, authMethod: null }; + + if (subOrganizationSelector) + throw new BadRequestError({ message: `Service token doesn't support sub organization selector` }); + + req.auth = { authMode: AuthMode.SCIM_TOKEN, actor, scimTokenId, orgId, authMethod: null, parentOrgId: orgId }; break; } default: diff --git a/backend/src/server/plugins/auth/inject-permission.ts b/backend/src/server/plugins/auth/inject-permission.ts index 11a94657b..da5e7e54e 100644 --- a/backend/src/server/plugins/auth/inject-permission.ts +++ b/backend/src/server/plugins/auth/inject-permission.ts @@ -14,7 +14,8 @@ export const injectPermission = fp(async (server) => { type: ActorType.USER, id: req.auth.userId, orgId: req.auth.orgId, // if the req.auth.authMode is AuthMode.API_KEY, the orgId will be "API_KEY" - authMethod: req.auth.authMethod // if the req.auth.authMode is AuthMode.API_KEY, the authMethod will be null + authMethod: req.auth.authMethod, // if the req.auth.authMode is AuthMode.API_KEY, the authMethod will be null + parentOrgId: req.auth.parentOrgId }; logger.info( @@ -25,7 +26,8 @@ export const injectPermission = fp(async (server) => { type: ActorType.IDENTITY, id: req.auth.identityId, orgId: req.auth.orgId, - authMethod: null + authMethod: null, + parentOrgId: req.auth.parentOrgId }; logger.info( @@ -36,6 +38,7 @@ export const injectPermission = fp(async (server) => { type: ActorType.SERVICE, id: req.auth.serviceTokenId, orgId: req.auth.orgId, + parentOrgId: req.auth.orgId, authMethod: null }; @@ -47,6 +50,7 @@ export const injectPermission = fp(async (server) => { type: ActorType.SCIM_CLIENT, id: req.auth.scimTokenId, orgId: req.auth.orgId, + parentOrgId: req.auth.orgId, authMethod: null }; diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index 82df0dcb1..b30e3beaf 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -3,17 +3,19 @@ import { Knex } from "knex"; import { AccessScope, TAuthTokens, TAuthTokenSessions } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; -import { ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; +import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { AuthModeJwtTokenPayload, AuthModeRefreshJwtTokenPayload, AuthTokenType } from "../auth/auth-type"; import { TMembershipUserDALFactory } from "../membership-user/membership-user-dal"; import { TUserDALFactory } from "../user/user-dal"; import { TTokenDALFactory } from "./auth-token-dal"; import { TCreateTokenForUserDTO, TIssueAuthTokenDTO, TokenType, TValidateTokenForUserDTO } from "./auth-token-types"; +import { TOrgDALFactory } from "../org/org-dal"; type TAuthTokenServiceFactoryDep = { tokenDAL: TTokenDALFactory; userDAL: Pick; + orgDAL: Pick; membershipUserDAL: Pick; }; @@ -80,7 +82,7 @@ export const getTokenConfig = (tokenType: TokenType) => { } }; -export const tokenServiceFactory = ({ tokenDAL, userDAL, membershipUserDAL }: TAuthTokenServiceFactoryDep) => { +export const tokenServiceFactory = ({ tokenDAL, userDAL, membershipUserDAL, orgDAL }: TAuthTokenServiceFactoryDep) => { const createTokenForUser = async ({ type, userId, orgId, aliasId, payload }: TCreateTokenForUserDTO) => { const { token, ...tkCfg } = getTokenConfig(type); const appCfg = getConfig(); @@ -194,7 +196,7 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, membershipUserDAL }: TA }; // to parse jwt identity in inject identity plugin - const fnValidateJwtIdentity = async (token: AuthModeJwtTokenPayload) => { + const fnValidateJwtIdentity = async (token: AuthModeJwtTokenPayload, subOrganizationSelector?: string) => { const session = await tokenDAL.findOneTokenSession({ id: token.tokenVersionId, userId: token.userId @@ -207,22 +209,53 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, membershipUserDAL }: TA const user = await userDAL.findById(session.userId); if (!user || !user.isAccepted) throw new NotFoundError({ message: `User with ID '${session.userId}' not found` }); + let orgId = ""; + let parentOrgId = ""; if (token.organizationId) { - const orgMembership = await membershipUserDAL.findOne({ - actorUserId: user.id, - scopeOrgId: token.organizationId, - scope: AccessScope.Organization - }); + if (subOrganizationSelector) { + const subOrganization = await orgDAL.findOne({ + parentOrgId: token.organizationId, + slug: subOrganizationSelector + }); + if (!subOrganization) + throw new BadRequestError({ message: `Sub organization ${subOrganizationSelector} not found` }); - if (!orgMembership) { - throw new ForbiddenRequestError({ message: "User not member of organization" }); - } - if (!orgMembership.isActive) { - throw new ForbiddenRequestError({ message: "User organization membership is inactive" }); + const orgMembership = await membershipUserDAL.findOne({ + actorUserId: user.id, + scopeOrgId: subOrganization.id, + scope: AccessScope.Organization + }); + + if (!orgMembership) { + throw new ForbiddenRequestError({ message: "User not member of organization" }); + } + + if (!orgMembership.isActive) { + throw new ForbiddenRequestError({ message: "User organization membership is inactive" }); + } + orgId = subOrganization.id; + parentOrgId = token.organizationId; + } else { + const orgMembership = await membershipUserDAL.findOne({ + actorUserId: user.id, + scopeOrgId: token.organizationId, + scope: AccessScope.Organization + }); + + if (!orgMembership) { + throw new ForbiddenRequestError({ message: "User not member of organization" }); + } + + if (!orgMembership.isActive) { + throw new ForbiddenRequestError({ message: "User organization membership is inactive" }); + } + + orgId = token.organizationId; + parentOrgId = token.organizationId; } } - return { user, tokenVersionId: token.tokenVersionId, orgId: token.organizationId }; + return { user, tokenVersionId: token.tokenVersionId, orgId, parentOrgId }; }; return { diff --git a/backend/src/services/identity-access-token/identity-access-token-dal.ts b/backend/src/services/identity-access-token/identity-access-token-dal.ts index 19de362d8..ffdb78645 100644 --- a/backend/src/services/identity-access-token/identity-access-token-dal.ts +++ b/backend/src/services/identity-access-token/identity-access-token-dal.ts @@ -19,6 +19,7 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => { .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.IdentityAccessToken}.identityId`) .select(selectAllTableCols(TableName.IdentityAccessToken)) .select(db.ref("name").withSchema(TableName.Identity)) + .select(db.ref("orgId").withSchema(TableName.Identity).as("identityScopeOrgId")) .first(); return doc; diff --git a/backend/src/services/identity-access-token/identity-access-token-service.ts b/backend/src/services/identity-access-token/identity-access-token-service.ts index 1f6e4616b..0bca28903 100644 --- a/backend/src/services/identity-access-token/identity-access-token-service.ts +++ b/backend/src/services/identity-access-token/identity-access-token-service.ts @@ -10,6 +10,7 @@ import { TIdentityDALFactory } from "../identity/identity-dal"; import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; import { TIdentityAccessTokenDALFactory } from "./identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload, TRenewAccessTokenDTO } from "./identity-access-token-types"; +import { TOrgDALFactory } from "../org/org-dal"; type TIdentityAccessTokenServiceFactoryDep = { identityAccessTokenDAL: TIdentityAccessTokenDALFactory; @@ -19,6 +20,7 @@ type TIdentityAccessTokenServiceFactoryDep = { "updateIdentityAccessTokenStatus" | "getIdentityTokenDetailsInCache" >; membershipIdentityDAL: Pick; + orgDAL: Pick; }; export type TIdentityAccessTokenServiceFactory = ReturnType; @@ -27,7 +29,8 @@ export const identityAccessTokenServiceFactory = ({ identityAccessTokenDAL, accessTokenQueue, identityDAL, - membershipIdentityDAL + membershipIdentityDAL, + orgDAL }: TIdentityAccessTokenServiceFactoryDep) => { const validateAccessTokenExp = async (identityAccessToken: TIdentityAccessTokens) => { const { @@ -181,7 +184,11 @@ export const identityAccessTokenServiceFactory = ({ return { revokedToken }; }; - const fnValidateIdentityAccessToken = async (token: TIdentityAccessTokenJwtPayload, ipAddress?: string) => { + const fnValidateIdentityAccessToken = async ( + token: TIdentityAccessTokenJwtPayload, + subOrganizationSelector?: string, + ipAddress?: string + ) => { const identityAccessToken = await identityAccessTokenDAL.findOne({ [`${TableName.IdentityAccessToken}.id` as "id"]: token.identityAccessTokenId, isAccessTokenRevoked: false @@ -202,13 +209,36 @@ export const identityAccessTokenServiceFactory = ({ trustedIps: trustedIps as TIp[] }); } - const identityOrgMembership = await membershipIdentityDAL.findOne({ - scope: AccessScope.Organization, - actorIdentityId: identityAccessToken.identityId - }); + let orgId = ""; + const parentOrgId = identityAccessToken.identityScopeOrgId; - if (!identityOrgMembership) { - throw new BadRequestError({ message: "Identity does not belong to any organization" }); + if (subOrganizationSelector) { + const subOrganization = await orgDAL.findOne({ parentOrgId, slug: subOrganizationSelector }); + if (!subOrganizationSelector) + throw new BadRequestError({ message: `Sub organization ${subOrganizationSelector} not found` }); + + const identityOrgMembership = await membershipIdentityDAL.findOne({ + scope: AccessScope.Organization, + actorIdentityId: identityAccessToken.identityId, + scopeOrgId: subOrganization.id + }); + + if (!identityOrgMembership) { + throw new BadRequestError({ message: "Identity does not belong to any organization" }); + } + orgId = subOrganization.id; + } else { + const identityOrgMembership = await membershipIdentityDAL.findOne({ + scope: AccessScope.Organization, + actorIdentityId: identityAccessToken.identityId, + scopeOrgId: parentOrgId + }); + + if (!identityOrgMembership) { + throw new BadRequestError({ message: "Identity does not belong to any organization" }); + } + + orgId = parentOrgId; } let { accessTokenNumUses } = identityAccessToken; @@ -219,7 +249,7 @@ export const identityAccessTokenServiceFactory = ({ await validateAccessTokenExp({ ...identityAccessToken, accessTokenNumUses }); await accessTokenQueue.updateIdentityAccessTokenStatus(identityAccessToken.id, Number(accessTokenNumUses) + 1); - return { ...identityAccessToken, orgId: identityOrgMembership.scopeOrgId }; + return { ...identityAccessToken, orgId, parentOrgId }; }; return { renewAccessToken, revokeAccessToken, fnValidateIdentityAccessToken }; diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index d987c890c..077ddcdfb 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -27,6 +27,7 @@ import { import { generateKnexQueryFromScim } from "@app/lib/knex/scim"; import { OrgAuthMethod } from "./org-types"; +import { ActorType } from "../auth/auth-type"; export type TOrgDALFactory = ReturnType; @@ -64,6 +65,7 @@ export const orgDALFactory = (db: TDbClient) => { const buildBaseQuery = (orgIdSubquery: Knex.QueryBuilder) => { return db .replicaNode()(TableName.Organization) + .whereNull(`${TableName.Organization}.parentOrgId`) .whereIn(`${TableName.Organization}.id`, orgIdSubquery) .leftJoin(TableName.Project, `${TableName.Organization}.id`, `${TableName.Project}.orgId`) .leftJoin(TableName.Membership, `${TableName.Organization}.id`, `${TableName.Membership}.scopeOrgId`) @@ -154,11 +156,47 @@ export const orgDALFactory = (db: TDbClient) => { } }; + const listSubOrganizations = async (dto: { + actorId: string; + actorType: ActorType; + orgId: string; + isAccessible?: boolean; + limit?: number; + offset?: number; + }) => { + try { + // TODO(sub-org:group): check this when implement group support + const query = db + .replicaNode()(TableName.Organization) + .where(`${TableName.Organization}.parentOrgId`, dto.orgId) + .select(selectAllTableCols(TableName.Organization)); + + if (dto.isAccessible) { + void query.leftJoin(`${TableName.Membership}`, (qb) => { + void qb.on(`${TableName.Membership}.scope`, AccessScope.Organization); + if (dto.actorType === ActorType.IDENTITY) { + void qb.andOn(`${TableName.Membership}.actorIdentityId`, dto.actorId); + } else { + void qb.andOn(`${TableName.Membership}.actorUserId`, dto.actorId); + } + }); + } + if (dto.limit) void query.limit(dto.limit); + if (dto.offset) void query.offset(dto.offset); + + const orgs = await query; + return orgs; + } catch (error) { + throw new DatabaseError({ error, name: "List sub organization" }); + } + }; + const findOrgById = async (orgId: string) => { try { const org = (await db .replicaNode()(TableName.Organization) .where({ [`${TableName.Organization}.id` as "id"]: orgId }) + .whereNull(`${TableName.Organization}.parentOrgId`) .leftJoin(TableName.SamlConfig, (qb) => { qb.on(`${TableName.SamlConfig}.orgId`, "=", `${TableName.Organization}.id`).andOn( `${TableName.SamlConfig}.isActive`, @@ -195,6 +233,7 @@ export const orgDALFactory = (db: TDbClient) => { try { const org = (await db .replicaNode()(TableName.Organization) + .whereNull(`${TableName.Organization}.parentOrgId`) .where({ [`${TableName.Organization}.slug` as "slug"]: orgSlug }) .leftJoin(TableName.SamlConfig, (qb) => { qb.on(`${TableName.SamlConfig}.orgId`, "=", `${TableName.Organization}.id`).andOn( @@ -240,6 +279,7 @@ export const orgDALFactory = (db: TDbClient) => { .whereNotNull(`${TableName.Membership}.actorUserId`) .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) .join(TableName.Organization, `${TableName.Membership}.scopeOrgId`, `${TableName.Organization}.id`) + .whereNull(`${TableName.Organization}.parentOrgId`) .leftJoin(TableName.SamlConfig, (qb) => { qb.on(`${TableName.SamlConfig}.orgId`, "=", `${TableName.Organization}.id`).andOn( `${TableName.SamlConfig}.isActive`, @@ -337,6 +377,7 @@ export const orgDALFactory = (db: TDbClient) => { } }; + // TODO(sub-org): updated this logic later const countAllOrgMembers = async (orgId: string) => { try { interface CountResult { @@ -610,6 +651,7 @@ export const orgDALFactory = (db: TDbClient) => { }) .join(TableName.Users, `${TableName.Users}.id`, `${TableName.Membership}.actorUserId`) .join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.Membership}.scopeOrgId`) + .whereNull(`${TableName.Organization}.parentOrgId`) .leftJoin(TableName.UserAliases, function joinUserAlias() { this.on(`${TableName.UserAliases}.userId`, "=", `${TableName.Membership}.actorUserId`) .andOn(`${TableName.UserAliases}.orgId`, "=", `${TableName.Membership}.scopeOrgId`) @@ -648,6 +690,7 @@ export const orgDALFactory = (db: TDbClient) => { .replicaNode()(TableName.Membership) .where({ actorIdentityId: identityId }) .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .whereNull(`${TableName.Organization}.parentOrgId`) .whereNotNull(`${TableName.Membership}.actorIdentityId`) .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) .join(TableName.Organization, `${TableName.Membership}.scopeOrgId`, `${TableName.Organization}.id`) @@ -667,6 +710,7 @@ export const orgDALFactory = (db: TDbClient) => { findOrgByProjectId, findAllOrgMembers, countAllOrgMembers, + listSubOrganizations, findOrgById, findOrgBySlug, findAllOrgsByUserId,