diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index ef850bb8c..bd7a09d86 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -6,6 +6,7 @@ import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TSamlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service"; +import { TScimServiceFactory } from "@app/ee/services/scim/scim-service"; import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service"; import { TSecretApprovalRequestServiceFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-service"; import { TSecretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service"; @@ -105,6 +106,7 @@ declare module "fastify" { secretRotation: TSecretRotationServiceFactory; snapshot: TSecretSnapshotServiceFactory; saml: TSamlConfigServiceFactory; + scim: TScimServiceFactory; auditLog: TAuditLogServiceFactory; secretScanning: TSecretScanningServiceFactory; license: TLicenseServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 5bf563810..1b4e82176 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -83,6 +83,9 @@ import { TSamlConfigs, TSamlConfigsInsert, TSamlConfigsUpdate, + TScimTokens, + TScimTokensInsert, + TScimTokensUpdate, TSecretApprovalPolicies, TSecretApprovalPoliciesApprovers, TSecretApprovalPoliciesApproversInsert, @@ -262,6 +265,11 @@ declare module "knex/types/tables" { TIdentityProjectMembershipsInsert, TIdentityProjectMembershipsUpdate >; + [TableName.ScimToken]: Knex.CompositeTableType< + TScimTokens, + TScimTokensInsert, + TScimTokensUpdate + >; [TableName.SecretApprovalPolicy]: Knex.CompositeTableType< TSecretApprovalPolicies, TSecretApprovalPoliciesInsert, diff --git a/backend/src/db/migrations/20240208234120_scim-token.ts b/backend/src/db/migrations/20240208234120_scim-token.ts index 60753e360..92364dba2 100644 --- a/backend/src/db/migrations/20240208234120_scim-token.ts +++ b/backend/src/db/migrations/20240208234120_scim-token.ts @@ -7,15 +7,15 @@ export async function up(knex: Knex): Promise { if (!(await knex.schema.hasTable(TableName.ScimToken))) { await knex.schema.createTable(TableName.ScimToken, (t) => { t.string("id", 36).primary().defaultTo(knex.fn.uuid()); - t.bigInteger("tokenTTL").defaultTo(15552000).notNullable(); // 180 days second - t.datetime("tokenLastUsedAt"); + t.bigInteger("ttl").defaultTo(15552000).notNullable(); // 180 days second + t.string("description").notNullable(); t.uuid("orgId").notNullable(); t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); t.timestamps(true, true, true); }); } - await createOnUpdateTrigger(knex, TableName.IdentityAccessToken); + await createOnUpdateTrigger(knex, TableName.ScimToken); } export async function down(knex: Knex): Promise { diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 62b01ebd2..b330c90b7 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -26,6 +26,7 @@ export * from "./project-memberships"; export * from "./project-roles"; export * from "./projects"; export * from "./saml-configs"; +export * from "./scim-tokens"; export * from "./secret-approval-policies"; export * from "./secret-approval-policies-approvers"; export * from "./secret-approval-request-secret-tags"; diff --git a/backend/src/db/schemas/scim-tokens.ts b/backend/src/db/schemas/scim-tokens.ts new file mode 100644 index 000000000..ac391ca91 --- /dev/null +++ b/backend/src/db/schemas/scim-tokens.ts @@ -0,0 +1,21 @@ +// 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 ScimTokensSchema = z.object({ + id: z.string(), + ttl: z.coerce.number().default(15552000), + description: z.string(), + orgId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), +}); + +export type TScimTokens = z.infer; +export type TScimTokensInsert = Omit; +export type TScimTokensUpdate = Partial>; diff --git a/backend/src/ee/routes/v1/scim-router.ts b/backend/src/ee/routes/v1/scim-router.ts index 00478458a..2e2edac43 100644 --- a/backend/src/ee/routes/v1/scim-router.ts +++ b/backend/src/ee/routes/v1/scim-router.ts @@ -1,10 +1,13 @@ import jwt from "jsonwebtoken"; import { z } from "zod"; +import { ScimTokensSchema } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode, AuthTokenType } from "@app/services/auth/auth-type"; + + export const registerScimRouter = async (server: FastifyZodProvider) => { server.route({ url: "/", @@ -27,29 +30,177 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { url: "/Users", method: "GET", schema: { - params: z.object({}), + querystring: z.object({ + startIndex: z.coerce.number().default(1), + count: z.coerce.number().default(20), + filter: z.string().trim().optional() + }), response: { - 200: z.object({}) + 200: z.object({ // TODO: audit the response + Resources: z.array(z.object({ + id: z.string().trim(), + userName: z.string().trim(), + name: z.object({ + familyName: z.string().trim(), + givenName: z.string().trim() + }), + emails: z.array(z.object({ + primary: z.boolean(), + value: z.string().email(), + type: z.string().trim() + })), + displayName: z.string().trim(), + active: z.boolean() + })), + itemsPerPage: z.number(), + schemas: z.array(z.string()), + startIndex: z.number(), + totalResults: z.number(), + }) } }, - // onRequest: verifyAuth([]), - handler: async () => { - return { - hello: "world" - }; + onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), + handler: async (req) => { + const res = await req.server.services.scim.listUsers({ + offset: req.query.startIndex, + limit: req.query.count, + filter: req.query.filter + }); + return res; } }); server.route({ - url: "/tokens/organizations/:organizationId", // api/v1/scim/token/organizations/:organizationId + url: "/Users/:userId", + method: "GET", + schema: { + params: z.object({ + userId: z.string().trim() + }), + response: { + 201: z.object({ + schemas: z.array(z.string()), + id: z.string().trim(), + userName: z.string().trim(), + name: z.object({ + familyName: z.string().trim(), + givenName: z.string().trim() + }), + emails: z.array(z.object({ + primary: z.boolean(), + value: z.string().email(), + type: z.string().trim() + })), + displayName: z.string().trim(), + active: z.boolean() + }) + } + }, + onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), + handler: async (req) => { + const res = await req.server.services.scim.getUser(req.params.userId); + return res; + } + }); + + server.route({ + url: "/Users", + method: "POST", + schema: { + body: z.object({ + schemas: z.array(z.string()), + userName: z.string().trim(), + name: z.object({ + familyName: z.string().trim(), + givenName: z.string().trim() + }), + emails: z.array(z.object({ + primary: z.boolean(), + value: z.string().email(), + type: z.string().trim() + })), + displayName: z.string().trim(), + // locale: z.string().trim(), + // externalId: z.string().trim(), + // groups: z.array(z.object({ + // value: z.string().trim() + // })), + // password: z.string().trim(), + active: z.boolean() + }), + response: { + 200: z.object({ + schemas: z.array(z.string()), + id: z.string().trim(), + userName: z.string().trim(), + name: z.object({ + familyName: z.string().trim(), + givenName: z.string().trim() + }), + emails: z.array(z.object({ + primary: z.boolean(), + value: z.string().email(), + type: z.string().trim() + })), + displayName: z.string().trim(), + active: z.boolean() + }) + } + }, + onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), + handler: async (req, reply) => { + const user = await req.server.services.scim.createUser({ + email: req.body.emails[0].value, + firstName: req.body.name.givenName, + lastName: req.body.name.familyName, + orgId: req.permission.orgId as string + }); + + reply.code(201); + return user; + } + }); + + server.route({ + url: "/Users/:userId", + method: "PATCH", + schema: { + body: z.object({}), + response: { + 200: z.object({}) + } + }, + onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), + handler: async (req) => { + // TODO: update a user's attr + return {}; + } + }); + + server.route({ + url: "/Users/:userId", + method: "PUT", + schema: { + body: z.object({}), + response: { + 200: z.object({}) + } + }, + onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), + handler: async (req) => { + // TODO: update a user's profile + return {}; + } + }); + + server.route({ + url: "/scim-tokens", method: "POST", onRequest: verifyAuth([AuthMode.JWT]), schema: { - params: z.object({ - organizationId: z.string().trim() - }), body: z.object({ - description: z.string().trim(), + organizationId: z.string().trim(), + description: z.string().trim().default(""), ttl: z.number().min(0).default(0) }), response: { @@ -58,53 +209,53 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }) } }, - handler: async () => { - // TODO: create SCIM token logic - // TODO: create SCIM token controller - - const appCfg = getConfig(); - const scimToken = jwt.sign( - { - authTokenType: AuthTokenType.SCIM_TOKEN - }, - appCfg.AUTH_SECRET, - { - // expiresIn: identityAccessToken.accessTokenMaxTTL === 0 ? undefined : identityAccessToken.accessTokenMaxTTL - } - ); // TODO: add expiration + handler: async (req) => { + const { scimToken } = await server.services.scim.createScimToken({ + organizationId: req.body.organizationId, + description: req.body.description, + ttl: req.body.ttl + }); return { scimToken }; } }); server.route({ - url: "/tokens/organizations/:organizationId", // api/v1/scim/token/organizations/:organizationId + url: "/scim-tokens", method: "GET", onRequest: verifyAuth([AuthMode.JWT]), schema: { - params: z.object({ + querystring: z.object({ organizationId: z.string().trim() }), response: { 200: z.object({ - scimToken: z.string().trim() + scimTokens: z.array(ScimTokensSchema) }) } }, - handler: async () => { - // TODO: put into service file - - const appCfg = getConfig(); - const scimToken = jwt.sign( - { - authTokenType: AuthTokenType.SCIM_TOKEN - }, - appCfg.AUTH_SECRET, - { - // expiresIn: identityAccessToken.accessTokenMaxTTL === 0 ? undefined : identityAccessToken.accessTokenMaxTTL - } - ); // TODO: add expiration + handler: async (req) => { + const scimTokens = await server.services.scim.getScimTokens(req.query.organizationId); + return { scimTokens }; + } + }); + server.route({ + url: "/scim-tokens/:scimTokenId", + method: "DELETE", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + scimTokenId: z.string().trim() + }), + response: { + 200: z.object({ + scimToken: ScimTokensSchema + }) + } + }, + handler: async (req) => { + const scimToken = await server.services.scim.deleteScimToken(req.params.scimTokenId); return { scimToken }; } }); 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 011dffebe..dfa98b0e4 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -15,7 +15,7 @@ export type TListProjectAuditLogDTO = { export type TCreateAuditLogDTO = { event: Event; - actor: UserActor | IdentityActor | ServiceActor; + actor: UserActor | IdentityActor | ServiceActor | ScimIdpActor; orgId?: string; projectId?: string; } & BaseAuthData; @@ -120,7 +120,11 @@ export interface IdentityActor { metadata: IdentityActorMetadata; } -export type Actor = UserActor | ServiceActor | IdentityActor; +export interface ScimClientActor { + type: ActorType.SCIM_CLIENT; +} + +export type Actor = UserActor | ServiceActor | IdentityActor | ScimClientActor; interface GetSecretsEvent { type: EventType.GET_SECRETS; diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 9b1d4c203..d41e45778 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -23,8 +23,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ customAlerts: false, auditLogs: false, auditLogsRetentionDays: 0, - samlSSO: false, - scim: false, + samlSSO: true, + scim: true, status: null, trial_end: null, has_used_trial: true, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 354714489..bc83f43f0 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -24,8 +24,8 @@ export type TFeatureSet = { customAlerts: false; auditLogs: false; auditLogsRetentionDays: 0; - samlSSO: false; - scim: false; + samlSSO: true; + scim: true; status: null; trial_end: null; has_used_trial: true; diff --git a/backend/src/ee/services/scim/scim-dal.ts b/backend/src/ee/services/scim/scim-dal.ts new file mode 100644 index 000000000..05c21b80c --- /dev/null +++ b/backend/src/ee/services/scim/scim-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 TScimDALFactory = ReturnType; + +export const scimDALFactory = (db: TDbClient) => { + const scimTokenOrm = ormify(db, TableName.ScimToken); + return scimTokenOrm; +}; diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts new file mode 100644 index 000000000..12eb2e94a --- /dev/null +++ b/backend/src/ee/services/scim/scim-service.ts @@ -0,0 +1,229 @@ +import jwt from "jsonwebtoken"; + +import { + OrgMembershipRole, + OrgMembershipStatus +} from "@app/db/schemas"; +import { TScimDALFactory } from "@app/ee/services/scim/scim-dal"; +import { TUserDALFactory } from "@app/services/user/user-dal"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { TPermissionServiceFactory } from "../permission/permission-service"; +import { TLicenseServiceFactory } from "../license/license-service"; +import { getConfig } from "@app/lib/config/env"; +import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; +import { + TCreateScimTokenDTO, + TListScimUsersDTO, + TListScimUsersRes, + TCreateScimUserDTO, + TScimTokenJwtPayload +} from "./scim-types"; +import { + createScimUser, + TScimUser, +} from "@app/lib/scim"; +import { UnauthorizedError, ScimRequestError } from "@app/lib/errors"; + +type TScimServiceFactoryDep = { + permissionService: Pick; + scimDAL: TScimDALFactory; // TODO: pick + userDAL: TUserDALFactory; // TODO: pick + orgDAL: TOrgDALFactory; // TODO: pick + licenseService: Pick; +}; + +export type TScimServiceFactory = ReturnType; + +export const scimServiceFactory = ({ + licenseService, + scimDAL, + userDAL, + orgDAL, + permissionService +}: TScimServiceFactoryDep) => { + const createScimToken = async ({ + organizationId, + description, + ttl + }: TCreateScimTokenDTO) => { + const appCfg = getConfig(); + + // TODO: permission stuff + + const scimTokenData = await scimDAL.create({ + orgId: organizationId, + description, + ttl + }); + + const scimToken = jwt.sign( + { + scimTokenId: scimTokenData.id, + authTokenType: AuthTokenType.SCIM_TOKEN + }, + appCfg.AUTH_SECRET + ); + + return { scimToken } + } + + const getScimTokens = async (organizationId: string) => { + const scimTokens = await scimDAL.find({ orgId: organizationId }); + return scimTokens; + } + + const deleteScimToken = async (scimTokenId: string) => { + const scimToken = await scimDAL.deleteById(scimTokenId); + return scimToken; + } + + // scim server endpoints + + const listUsers = async ({ + offset, + limit, + filter + }: TListScimUsersDTO): Promise => { + + const parseFilter = (filter: string | undefined) => { + if (!filter) return {}; + const [parsedName, parsedValue] = filter.split("eq").map(s => s.trim()); + + let attributeName = parsedName; + if (parsedName === "userName") { // note + attributeName = "email"; + } + + return { [attributeName]: parsedValue }; + }; + + const findOpts = { + ...(offset && { offset }), + ...(limit && { limit }), + }; + + const users = await userDAL.find(parseFilter(filter), findOpts); + + let resources: TScimUser[] = []; + + let scimResource: TListScimUsersRes = { // note: type + Resources: [], + itemsPerPage: limit, + schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + startIndex: offset, + totalResults: users.length + }; + + users.forEach((user) => { + let scimUser = createScimUser({ + userId: user.id, + firstName: user.firstName as string, + lastName: user.lastName as string, + email: user.email + }); + resources.push(scimUser); + }); + + scimResource.Resources = resources; + + return scimResource; + } + + const getUser = async (userId: string) => { + // TODO: check out SCIM-specific errors + + let user; + try { + user = await userDAL.findById(userId); + } catch (error) { + + interface PostgresError extends Error { + error: { + code: string; + } + } + + const dbError = error as PostgresError; + + if (dbError.error.code === "22P02") throw new ScimRequestError({ + detail: "User not found", + status: 404 + }); + + throw error; + } + + if (!user) throw new ScimRequestError({ + detail: "User not found", + status: 404 + }); + + return createScimUser({ + userId: user.id, + firstName: user.firstName as string, + lastName: user.lastName as string, + email: user.email + }); + } + + const createUser = async ({ + firstName, + lastName, + email, + orgId + }: TCreateScimUserDTO) => { + let user = await userDAL.findOne({ + email + }); + + if (user) throw new ScimRequestError({ + detail: "User already exists in the database", + status: 409 + }); + + user = await userDAL.transaction(async (tx) => { + const newUser = await userDAL.create( + { + email, + firstName, + lastName, + authMethods: [AuthMethod.EMAIL] + }, + tx + ); + await orgDAL.createMembership({ + inviteEmail: email, + orgId, + role: OrgMembershipRole.Member, + status: OrgMembershipStatus.Invited + }); + return newUser; + }); + + return createScimUser({ + userId: user.id, + firstName: user.firstName as string, + lastName: user.lastName as string, + email: user.email + }); + } + + const fnValidateScimToken = async (token: TScimTokenJwtPayload) => { + // TODO: check expiry + + const scimToken = await scimDAL.findById(token.scimTokenId); + if (!scimToken) throw new UnauthorizedError(); + + return { scimTokenId: scimToken.id, orgId: scimToken.orgId }; + } + + return { + createScimToken, + getScimTokens, + deleteScimToken, + listUsers, + getUser, + createUser, + fnValidateScimToken + }; +}; diff --git a/backend/src/ee/services/scim/scim-types.ts b/backend/src/ee/services/scim/scim-types.ts new file mode 100644 index 000000000..a10e9cc8e --- /dev/null +++ b/backend/src/ee/services/scim/scim-types.ts @@ -0,0 +1,41 @@ +import { TOrgPermission } from "@app/lib/types"; +import { TScimUser } from "@app/lib/scim"; + +export type TCreateScimTokenDTO = { + organizationId: string; + description: string; + ttl: number; +} + +// TODO: add org permissions +// & Omit; + +export type TListScimUsersDTO = { + offset: number; + limit: number; + filter?: string; +} + +export type TListScimUsersRes = { // check naming here + schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"]; + totalResults: number; + Resources: TScimUser[]; + itemsPerPage: number; + startIndex: number; +} + +export type TCreateScimUserDTO = { + email: string; + firstName: string; + lastName: string; + orgId: string; +} + +export type TCreateScimUserRes = { + schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"] +} + +export type TScimTokenJwtPayload = { + scimTokenId: string; + authTokenType: string; +}; \ No newline at end of file diff --git a/backend/src/lib/errors/index.ts b/backend/src/lib/errors/index.ts index b4376f007..a46d02066 100644 --- a/backend/src/lib/errors/index.ts +++ b/backend/src/lib/errors/index.ts @@ -58,3 +58,20 @@ export class BadRequestError extends Error { this.error = error; } } + +export class ScimRequestError extends Error { + name: string; + schemas: string[]; + detail: string; + status: number; + error: unknown; + + constructor({ name, error, detail, status }: { message?: string; name?: string; error?: unknown, detail: string, status: number }) { + super(detail ?? "The request is invalid"); + this.name = name || "ScimRequestError"; + this.schemas = ["urn:ietf:params:scim:api:messages:2.0:Error"]; + this.error = error; + this.detail = detail; + this.status = status; + } +} \ No newline at end of file diff --git a/backend/src/lib/scim/fns.ts b/backend/src/lib/scim/fns.ts new file mode 100644 index 000000000..430dd8fbb --- /dev/null +++ b/backend/src/lib/scim/fns.ts @@ -0,0 +1,39 @@ +import { TScimUser } from "./types"; + +export const createScimUser = ({ + userId, + firstName, + lastName, + email +}: { + userId: string; + firstName: string; + lastName: string; + email: string; +}): TScimUser => { + let scimUser = { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": userId, + "userName": email, + "displayName": `${firstName} ${lastName}`, + "name": { + "givenName": firstName, + "middleName": null, + "familyName": lastName + }, + "emails": + [{ + "primary": true, + "value": email, + "type": "work" + }], + "active": true, + "groups": [], + "meta": { + "resourceType": "User", + "location": null + } + }; + + return scimUser; +} \ No newline at end of file diff --git a/backend/src/lib/scim/index.ts b/backend/src/lib/scim/index.ts new file mode 100644 index 000000000..8ab53e080 --- /dev/null +++ b/backend/src/lib/scim/index.ts @@ -0,0 +1,4 @@ +export { TScimUser } from "./types"; +export { + createScimUser +} from "./fns"; \ No newline at end of file diff --git a/backend/src/lib/scim/types.ts b/backend/src/lib/scim/types.ts new file mode 100644 index 000000000..f439c004e --- /dev/null +++ b/backend/src/lib/scim/types.ts @@ -0,0 +1,23 @@ + +export type TScimUser = { + schemas: string[]; + id: string; + userName: string; + displayName: string; + name: { + givenName: string; + middleName: null; + familyName: string; + }; + emails: { + primary: boolean; + value: string; + type: string; + }[]; + active: boolean; + groups: string[]; + meta: { + resourceType: string; + location: null; + }; +} \ No newline at end of file diff --git a/backend/src/server/plugins/audit-log.ts b/backend/src/server/plugins/audit-log.ts index b42cc2ff2..33144683c 100644 --- a/backend/src/server/plugins/audit-log.ts +++ b/backend/src/server/plugins/audit-log.ts @@ -63,6 +63,10 @@ export const injectAuditLogInfo = fp(async (server: FastifyZodProvider) => { identityId: req.auth.identityId } }; + } else if (req.auth.actor === ActorType.SCIM_CLIENT) { + payload.actor = { + type: ActorType.SCIM_CLIENT + }; } else { throw new BadRequestError({ message: "Missing logic for other actor" }); } diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index a819f66d8..7066764ff 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -7,6 +7,7 @@ import { getConfig } from "@app/lib/config/env"; import { UnauthorizedError } from "@app/lib/errors"; import { ActorType, AuthMode, AuthModeJwtTokenPayload, AuthTokenType } from "@app/services/auth/auth-type"; import { TIdentityAccessTokenJwtPayload } from "@app/services/identity-access-token/identity-access-token-types"; +import { TScimTokenJwtPayload } from "@app/ee/services/scim/scim-types"; export type TAuthMode = | { @@ -38,7 +39,9 @@ export type TAuthMode = } | { authMode: AuthMode.SCIM_TOKEN; - actor: ActorType.SCIM_IDP; + actor: ActorType.SCIM_CLIENT; + scimTokenId: string; + orgId: string; }; const extractAuth = async (req: FastifyRequest, jwtSecret: string) => { @@ -78,8 +81,8 @@ const extractAuth = async (req: FastifyRequest, jwtSecret: string) => { case AuthTokenType.SCIM_TOKEN: return { authMode: AuthMode.SCIM_TOKEN, - token: decodedToken, - actor: ActorType.SCIM_IDP + token: decodedToken as TScimTokenJwtPayload, + actor: ActorType.SCIM_CLIENT } as const; default: return { authMode: null, token: null } as const; @@ -125,7 +128,8 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { break; } case AuthMode.SCIM_TOKEN: { - req.auth = { authMode: AuthMode.SCIM_TOKEN, actor }; + const { orgId, scimTokenId } = await server.services.scim.fnValidateScimToken(token); + req.auth = { authMode: AuthMode.SCIM_TOKEN, actor, scimTokenId, orgId }; break; } default: diff --git a/backend/src/server/plugins/auth/inject-permission.ts b/backend/src/server/plugins/auth/inject-permission.ts index 572814d64..2d61647e8 100644 --- a/backend/src/server/plugins/auth/inject-permission.ts +++ b/backend/src/server/plugins/auth/inject-permission.ts @@ -14,6 +14,8 @@ export const injectPermission = fp(async (server) => { req.permission = { type: ActorType.IDENTITY, id: req.auth.identityId }; } else if (req.auth.actor === ActorType.SERVICE) { req.permission = { type: ActorType.SERVICE, id: req.auth.serviceTokenId }; + } else if (req.auth.actor === ActorType.SCIM_CLIENT) { + req.permission = { type: ActorType.SCIM_CLIENT, id: req.auth.scimTokenId, orgId: req.auth.orgId }; } }); }); diff --git a/backend/src/server/plugins/error-handler.ts b/backend/src/server/plugins/error-handler.ts index 8587c93bd..ac1912030 100644 --- a/backend/src/server/plugins/error-handler.ts +++ b/backend/src/server/plugins/error-handler.ts @@ -2,7 +2,7 @@ import { ForbiddenError } from "@casl/ability"; import fastifyPlugin from "fastify-plugin"; import { ZodError } from "zod"; -import { BadRequestError, DatabaseError, InternalServerError, UnauthorizedError } from "@app/lib/errors"; +import { BadRequestError, DatabaseError, InternalServerError, UnauthorizedError, ScimRequestError } from "@app/lib/errors"; export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider) => { server.setErrorHandler((error, req, res) => { @@ -21,6 +21,12 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider error: "PermissionDenied", message: `You are not allowed to ${error.action} on ${error.subjectType}` }); + } else if (error instanceof ScimRequestError) { + void res.status(error.status).send({ + schemas: error.schemas, + status: error.status, + detail: error.detail + }); } else { void res.send(error); } diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 28f65ea99..b07799217 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -32,6 +32,8 @@ import { snapshotFolderDALFactory } from "@app/ee/services/secret-snapshot/snaps import { snapshotSecretDALFactory } from "@app/ee/services/secret-snapshot/snapshot-secret-dal"; import { trustedIpDALFactory } from "@app/ee/services/trusted-ip/trusted-ip-dal"; import { trustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service"; +import { scimDALFactory } from "@app/ee/services/scim/scim-dal"; +import { scimServiceFactory } from "@app/ee/services/scim/scim-service"; import { getConfig } from "@app/lib/config/env"; import { TQueueServiceFactory } from "@app/queue"; import { apiKeyDALFactory } from "@app/services/api-key/api-key-dal"; @@ -155,6 +157,7 @@ export const registerRoutes = async ( const auditLogDAL = auditLogDALFactory(db); const trustedIpDAL = trustedIpDALFactory(db); + const scimDAL = scimDALFactory(db); // ee db layer ops const permissionDAL = permissionDALFactory(db); @@ -188,6 +191,13 @@ export const registerRoutes = async ( trustedIpDAL, permissionService }); + const scimService = scimServiceFactory({ + licenseService, + scimDAL, + userDAL, + orgDAL, + permissionService + }); const auditLogQueue = auditLogQueueServiceFactory({ auditLogDAL, queueService, @@ -486,6 +496,7 @@ export const registerRoutes = async ( secretScanning: secretScanningService, license: licenseService, trustedIp: trustedIpService, + scim: scimService, secretBlindIndex: secretBlindIndexService, telemetry: telemetryService }); diff --git a/backend/src/services/auth/auth-type.ts b/backend/src/services/auth/auth-type.ts index 321140ff7..b051952e7 100644 --- a/backend/src/services/auth/auth-type.ts +++ b/backend/src/services/auth/auth-type.ts @@ -34,7 +34,7 @@ export enum ActorType { // would extend to AWS, Azure, ... SERVICE = "service", IDENTITY = "identity", Machine = "machine", - SCIM_IDP = "scimIdp" + SCIM_CLIENT = "scimClient" } export type AuthModeJwtTokenPayload = { diff --git a/frontend/src/hooks/api/scim/index.tsx b/frontend/src/hooks/api/scim/index.tsx index 13a190c1f..389518173 100644 --- a/frontend/src/hooks/api/scim/index.tsx +++ b/frontend/src/hooks/api/scim/index.tsx @@ -1,3 +1,5 @@ -export { - useGetScimToken -} from "./queries"; \ No newline at end of file +export { useGetScimTokens } from "./queries"; +export { + useCreateScimToken, + useDeleteScimToken +} from "./mutations"; \ No newline at end of file diff --git a/frontend/src/hooks/api/scim/mutations.tsx b/frontend/src/hooks/api/scim/mutations.tsx new file mode 100644 index 000000000..d0646b98b --- /dev/null +++ b/frontend/src/hooks/api/scim/mutations.tsx @@ -0,0 +1,46 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiRequest } from "@app/config/request"; +import { + CreateScimTokenDTO, + CreateScimTokenRes, + DeleteScimTokenDTO +} from "./types"; +import { scimKeys } from "./queries"; + +export const useCreateScimToken = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + organizationId, + description, + ttl + }) => { + const { data } = await apiRequest.post("/api/v1/scim/scim-tokens", { + organizationId, + description, + ttl + }); + + return data; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(scimKeys.getScimTokens(organizationId)); + } + }); +}; + +export const useDeleteScimToken = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + organizationId, + scimTokenId + }) => { + const { data } = await apiRequest.delete(`/api/v1/scim/scim-tokens/${scimTokenId}`); + return data; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(scimKeys.getScimTokens(organizationId)); + } + }); +}; \ No newline at end of file diff --git a/frontend/src/hooks/api/scim/queries.tsx b/frontend/src/hooks/api/scim/queries.tsx index b8a3291b1..4c88ebc2f 100644 --- a/frontend/src/hooks/api/scim/queries.tsx +++ b/frontend/src/hooks/api/scim/queries.tsx @@ -1,23 +1,22 @@ -import { useQuery } from "@tanstack/react-query"; - +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { ScimTokenData } from "./types"; -import { GetScimTokenRes } from "./types"; - -const scimKeys = { - getScimToken: (orgId: string) => [{ orgId }, "organization-scim-token"] as const, +export const scimKeys = { + getScimTokens: (orgId: string) => [{ orgId }, "organization-scim-token"] as const, }; -export const useGetScimToken = (organizationId: string) => { +export const useGetScimTokens = (organizationId: string) => { return useQuery({ - queryKey: scimKeys.getScimToken(organizationId), + queryKey: scimKeys.getScimTokens(organizationId), queryFn: async () => { if (organizationId === "") { return undefined; } - const { data: { scimToken } } = await apiRequest.get(`/api/v1/scim/token/organizations/${organizationId}`); - return scimToken; + const { data: { scimTokens } } = await apiRequest.get<{ scimTokens: ScimTokenData[] }>(`/api/v1/scim/scim-tokens?organizationId=${organizationId}`); + + return scimTokens; }, enabled: true }); diff --git a/frontend/src/hooks/api/scim/types.ts b/frontend/src/hooks/api/scim/types.ts index deec75ed0..814ce6013 100644 --- a/frontend/src/hooks/api/scim/types.ts +++ b/frontend/src/hooks/api/scim/types.ts @@ -1,3 +1,24 @@ -export type GetScimTokenRes = { +export type ScimTokenData = { + id: string; + ttl: number; + description: string; + tokenSuffix: string; + orgId: string; + createdAt: string; + updatedAt: string; +}; + +export type CreateScimTokenDTO = { + organizationId: string; + description?: string; + ttl?: number; +} + +export type DeleteScimTokenDTO = { + organizationId: string; + scimTokenId: string; +} + +export type CreateScimTokenRes = { scimToken: string; -}; \ No newline at end of file +} \ No newline at end of file diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index b975f0472..5680da02d 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -18,6 +18,7 @@ export type SubscriptionPlan = { workspacesUsed: number; environmentLimit: number; samlSSO: boolean; + scim: boolean; status: | "incomplete" | "incomplete_expired" diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgSCIMSection.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgSCIMSection.tsx index 66e72d39e..0c69552d0 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgSCIMSection.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgSCIMSection.tsx @@ -7,52 +7,47 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Button, IconButton, - Switch + Switch, + UpgradePlanModal } from "@app/components/v2"; import { // OrgPermissionActions, // OrgPermissionSubjects, useOrganization, -// useSubscription + useSubscription } from "@app/context"; -import { useToggle } from "@app/hooks"; -// import { usePopUp } from "@app/hooks/usePopUp"; -import { useGetScimToken } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; +import { ScimTokenModal } from "./ScimTokenModal"; // TODO: add permissioning for enteprise SCIM export const OrgScimSection = () => { const { currentOrg } = useOrganization(); // const { createNotification } = useNotificationContext(); - // const { subscription } = useSubscription(); - // const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ - // "upgradePlan" - // ] as const); - - const { data: scimToken } = useGetScimToken(currentOrg?.id ?? ""); + const { subscription } = useSubscription(); + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "scimToken", + "deleteScimToken", + "upgradePlan" + ] as const); const [scimEnabled, setScimEnabled] = useState(false); // sync this with backend - const [isAPIKeyCopied, setIsAPIKeyCopied] = useToggle(false); - - // TODO: get SCIM stuf const addScimTokenBtnClick = () => { - + if (subscription?.scim) { + handlePopUpOpen("scimToken"); + } else { + handlePopUpOpen("upgradePlan"); + } } const handleSCIMToggle = (value: boolean) => { - // TODO try { setScimEnabled(value); } catch (err) { console.error(err); } } - - const copyTokenToClipboard = () => { - navigator.clipboard.writeText(scimToken ?? ""); - setIsAPIKeyCopied.on(); - }; return (
@@ -64,7 +59,7 @@ export const OrgScimSection = () => { // isDisabled={!isAllowed} leftIcon={} > - Add SCIM Token + Manage SCIM Tokens
{ > Enable SCIM Provisioning - {scimEnabled && ( -
-
-

SCIM URL

-

{`${window.origin}/api/v1/scim`}

-
- {/*

SCIM URL

*/} - {/*
-

{`${window.origin}/api/v1/scim`}

- - - - Click to copy - - -
*/} - {scimToken && ( - <> -

SCIM Bearer Token

-
-

{scimToken}

- - - - Click to copy - - -
- - )} -
- )} + + handlePopUpToggle("upgradePlan", isOpen)} + text="You can use SCIM Provisioning if you switch to Infisical's Pro plan." + /> ); } \ No newline at end of file diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/ScimTokenModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/ScimTokenModal.tsx index e69de29bb..329900b14 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/ScimTokenModal.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/ScimTokenModal.tsx @@ -0,0 +1,353 @@ +import { useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { faCheck, faCopy, faKey, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { format } from "date-fns"; +import * as yup from "yup"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + DeleteActionModal, + EmptyState, + FormControl, + IconButton, + Input, + Modal, + ModalContent, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { useToggle } from "@app/hooks"; +import { + useGetScimTokens, + useCreateScimToken, + useDeleteScimToken +} from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; +import { useOrganization } from "@app/context"; + +// TODO: turn TTL into a select component + +const schema = yup.object({ + description: yup.string(), + ttl: yup.string() +}); + +export type FormData = yup.InferType; + +type Props = { + popUp: UsePopUpState<["scimToken", "deleteScimToken"]>; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["deleteScimToken"]>, + data?: { + scimTokenId: string; + } + ) => void; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState< + ["scimToken", "deleteScimToken"] + >, + state?: boolean + ) => void; +}; + +export const ScimTokenModal = ({ + popUp, + handlePopUpOpen, + handlePopUpToggle +}: Props) => { + const { currentOrg } = useOrganization(); + const { t } = useTranslation(); + const { createNotification } = useNotificationContext(); + const [token, setToken] = useState(""); + + const [isScimUrlCopied, setIsScimUrlCopied] = useToggle(false); + const [isScimTokenCopied, setIsScimTokenCopied] = useToggle(false); + + const { data, isLoading } = useGetScimTokens(currentOrg?.id ?? ""); + const { mutateAsync: createScimTokenMutateAsync } = useCreateScimToken(); + const { mutateAsync: deleteScimTokenMutateAsync } = useDeleteScimToken(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: yupResolver(schema), + defaultValues: { + description: "", + ttl: "" + } + }); + + useEffect(() => { + let timer: NodeJS.Timeout; + if (isScimUrlCopied) { + timer = setTimeout(() => setIsScimUrlCopied.off(), 2000); + } + + if (isScimTokenCopied) { + timer = setTimeout(() => setIsScimTokenCopied.off(), 2000); + } + + return () => clearTimeout(timer); + }, [isScimTokenCopied, isScimUrlCopied]); + + const onFormSubmit = async ({ description, ttl }: FormData) => { + try { + if (!currentOrg?.id) return; + + const { scimToken } = await createScimTokenMutateAsync({ + organizationId: currentOrg.id, + description, + ttl: Number(ttl) + }); + + setToken(scimToken); + + createNotification({ + text: "Successfully created SCIM token", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to create SCIM token", + type: "error" + }); + } + }; + + const onDeleteScimTokenSubmit = async (scimTokenId: string) => { + try { + if (!currentOrg?.id) return; + + await deleteScimTokenMutateAsync({ + organizationId: currentOrg.id, + scimTokenId + }); + + // TODO: find alt way + + // if (token.startsWith(clientSecretPrefix)) { + // reset(); + // setToken(""); + // } + + handlePopUpToggle("deleteScimToken", false); + + createNotification({ + text: "Successfully deleted SCIM token", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete SCIM token", + type: "error" + }); + } + }; + + const hasToken = Boolean(token); + const scimUrl = `${window.origin}/api/v1/scim`; + + return ( + { + handlePopUpToggle("scimToken", isOpen); + reset(); + setToken(""); + }} + > + +

SCIM URL

+
+

{scimUrl}

+ { + navigator.clipboard.writeText(scimUrl); + setIsScimUrlCopied.on(); + }} + > + + + {t("common.click-to-copy")} + + +
+

New SCIM Token

+ {hasToken ? ( +
+
+

We will only show this token once

+ +
+
+

{token}

+ { + navigator.clipboard.writeText(token); + setIsScimTokenCopied.on(); + }} + > + + + {t("common.click-to-copy")} + + +
+
+ ) : ( +
+ ( + + + + )} + /> + ( + +
+ + +
+
+ )} + /> + + )} +

SCIM Tokens

+ + + + + + + + + + + {isLoading && } + {!isLoading && + data && + data.length > 0 && + data.map( + ({ + id, + description, + ttl, + createdAt + }) => { + + let expiresAt; + if (ttl > 0) { + expiresAt = new Date(new Date(createdAt).getTime() + ttl * 1000); + } + + return ( + + + + + + + ); + } + )} + {!isLoading && data && data?.length === 0 && ( + + + + )} + +
DescriptionExpires AtCreated At +
{description === "" ? "-" : description}{expiresAt ? format(expiresAt, "yyyy-MM-dd") : "-"}{format(new Date(createdAt), "yyyy-MM-dd HH:mm:ss")} + { + handlePopUpOpen("deleteScimToken", { + scimTokenId: id + }); + }} + size="lg" + colorSchema="primary" + variant="plain" + ariaLabel="update" + > + + +
+ +
+
+ handlePopUpToggle("scimToken", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => { + + const deleteScimTokenData = popUp?.deleteScimToken?.data as { + scimTokenId: string; + }; + + return onDeleteScimTokenSubmit(deleteScimTokenData.scimTokenId); + }} + /> +
+
+ ); +};