From d5064fe75a57125bc734ad03d2a983e03f4a967a Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Thu, 8 Feb 2024 15:54:20 -0800 Subject: [PATCH] Start SCIM functionality --- .../migrations/20240208234120_scim-token.ts | 24 ++++ backend/src/db/schemas/models.ts | 1 + backend/src/ee/routes/v1/index.ts | 2 + backend/src/ee/routes/v1/scim-router.ts | 111 ++++++++++++++++++ .../server/plugins/auth/inject-identity.ts | 15 +++ backend/src/services/auth/auth-type.ts | 9 +- frontend/src/hooks/api/index.tsx | 1 + frontend/src/hooks/api/scim/index.tsx | 3 + frontend/src/hooks/api/scim/queries.tsx | 24 ++++ frontend/src/hooks/api/scim/types.ts | 3 + .../components/OrgAuthTab/OrgAuthTab.tsx | 2 + .../components/OrgAuthTab/OrgSCIMSection.tsx | 108 +++++++++++++++++ 12 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 backend/src/db/migrations/20240208234120_scim-token.ts create mode 100644 backend/src/ee/routes/v1/scim-router.ts create mode 100644 frontend/src/hooks/api/scim/index.tsx create mode 100644 frontend/src/hooks/api/scim/queries.tsx create mode 100644 frontend/src/hooks/api/scim/types.ts create mode 100644 frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgSCIMSection.tsx diff --git a/backend/src/db/migrations/20240208234120_scim-token.ts b/backend/src/db/migrations/20240208234120_scim-token.ts new file mode 100644 index 000000000..60753e360 --- /dev/null +++ b/backend/src/db/migrations/20240208234120_scim-token.ts @@ -0,0 +1,24 @@ +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.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.uuid("orgId").notNullable(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.IdentityAccessToken); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.ScimToken); + await dropOnUpdateTrigger(knex, TableName.ScimToken); +} diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 4ef943bbe..e817d6ed0 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -40,6 +40,7 @@ export enum TableName { IdentityUaClientSecret = "identity_ua_client_secrets", IdentityOrgMembership = "identity_org_memberships", IdentityProjectMembership = "identity_project_memberships", + ScimToken = "scim_tokens", SecretApprovalPolicy = "secret_approval_policies", SecretApprovalPolicyApprover = "secret_approval_policies_approvers", SecretApprovalRequest = "secret_approval_requests", diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 2ed439316..fb92aa3c5 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -3,6 +3,7 @@ import { registerOrgRoleRouter } from "./org-role-router"; import { registerProjectRoleRouter } from "./project-role-router"; import { registerProjectRouter } from "./project-router"; import { registerSamlRouter } from "./saml-router"; +import { registerScimRouter } from "./scim-router"; import { registerSecretApprovalPolicyRouter } from "./secret-approval-policy-router"; import { registerSecretApprovalRequestRouter } from "./secret-approval-request-router"; import { registerSecretRotationProviderRouter } from "./secret-rotation-provider-router"; @@ -33,6 +34,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { prefix: "/secret-rotation-providers" }); await server.register(registerSamlRouter, { prefix: "/sso" }); + await server.register(registerScimRouter, { prefix: "/scim" }); await server.register(registerSecretScanningRouter, { prefix: "/secret-scanning" }); await server.register(registerSecretRotationRouter, { prefix: "/secret-rotations" }); await server.register(registerSecretVersionRouter, { prefix: "/secret" }); diff --git a/backend/src/ee/routes/v1/scim-router.ts b/backend/src/ee/routes/v1/scim-router.ts new file mode 100644 index 000000000..00478458a --- /dev/null +++ b/backend/src/ee/routes/v1/scim-router.ts @@ -0,0 +1,111 @@ +import jwt from "jsonwebtoken"; +import { z } from "zod"; + +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: "/", + method: "GET", + schema: { + params: z.object({}), + response: { + 200: z.object({}) + } + }, + // onRequest: verifyAuth([AuthMode.JWT]), + handler: async () => { + return { + hello: "world" + }; + } + }); + + server.route({ + url: "/Users", + method: "GET", + schema: { + params: z.object({}), + response: { + 200: z.object({}) + } + }, + // onRequest: verifyAuth([]), + handler: async () => { + return { + hello: "world" + }; + } + }); + + server.route({ + url: "/tokens/organizations/:organizationId", // api/v1/scim/token/organizations/:organizationId + method: "POST", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + organizationId: z.string().trim() + }), + body: z.object({ + description: z.string().trim(), + ttl: z.number().min(0).default(0) + }), + response: { + 200: z.object({ + scimToken: z.string().trim() + }) + } + }, + 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 + + return { scimToken }; + } + }); + + server.route({ + url: "/tokens/organizations/:organizationId", // api/v1/scim/token/organizations/:organizationId + method: "GET", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + organizationId: z.string().trim() + }), + response: { + 200: z.object({ + scimToken: z.string().trim() + }) + } + }, + 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 + + return { scimToken }; + } + }); +}; diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index 04d4cbe0e..60d06ecfb 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -33,6 +33,10 @@ export type TAuthMode = actor: ActorType.IDENTITY; identityId: string; identityName: string; + } + | { + authMode: AuthMode.SCIM_TOKEN; + actor: ActorType.SCIM_IDP; }; const extractAuth = async (req: FastifyRequest, jwtSecret: string) => { @@ -53,6 +57,7 @@ const extractAuth = async (req: FastifyRequest, jwtSecret: string) => { } const decodedToken = jwt.verify(authTokenValue, jwtSecret) as JwtPayload; + switch (decodedToken.authTokenType) { case AuthTokenType.ACCESS_TOKEN: return { @@ -68,6 +73,12 @@ const extractAuth = async (req: FastifyRequest, jwtSecret: string) => { token: decodedToken as TIdentityAccessTokenJwtPayload, actor: ActorType.IDENTITY } as const; + case AuthTokenType.SCIM_TOKEN: + return { + authMode: AuthMode.SCIM_TOKEN, + token: decodedToken, + actor: ActorType.SCIM_IDP + } as const; default: return { authMode: null, token: null } as const; } @@ -111,6 +122,10 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { req.auth = { authMode: AuthMode.API_KEY as const, userId: user.id, actor, user }; break; } + case AuthMode.SCIM_TOKEN: { + req.auth = { authMode: AuthMode.SCIM_TOKEN, actor }; + break; + } default: throw new UnauthorizedError({ name: "Unknown token strategy" }); } diff --git a/backend/src/services/auth/auth-type.ts b/backend/src/services/auth/auth-type.ts index 26417b0e8..779d828c5 100644 --- a/backend/src/services/auth/auth-type.ts +++ b/backend/src/services/auth/auth-type.ts @@ -17,21 +17,24 @@ export enum AuthTokenType { API_KEY = "apiKey", SERVICE_ACCESS_TOKEN = "serviceAccessToken", SERVICE_REFRESH_TOKEN = "serviceRefreshToken", - IDENTITY_ACCESS_TOKEN = "identityAccessToken" + IDENTITY_ACCESS_TOKEN = "identityAccessToken", + SCIM_TOKEN = "scimToken" } export enum AuthMode { JWT = "jwt", SERVICE_TOKEN = "serviceToken", API_KEY = "apiKey", - IDENTITY_ACCESS_TOKEN = "identityAccessToken" + IDENTITY_ACCESS_TOKEN = "identityAccessToken", + SCIM_TOKEN = "scimToken" } export enum ActorType { // would extend to AWS, Azure, ... USER = "user", // userIdentity SERVICE = "service", IDENTITY = "identity", - Machine = "machine" + Machine = "machine", + SCIM_IDP = "scimIdp" } export type AuthModeJwtTokenPayload = { diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 4caeaea48..d63a5e111 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -10,6 +10,7 @@ export * from "./integrations"; export * from "./keys"; export * from "./organization"; export * from "./roles"; +export * from "./scim"; export * from "./secretApproval"; export * from "./secretApprovalRequest"; export * from "./secretFolders"; diff --git a/frontend/src/hooks/api/scim/index.tsx b/frontend/src/hooks/api/scim/index.tsx new file mode 100644 index 000000000..13a190c1f --- /dev/null +++ b/frontend/src/hooks/api/scim/index.tsx @@ -0,0 +1,3 @@ +export { + useGetScimToken +} from "./queries"; \ No newline at end of file diff --git a/frontend/src/hooks/api/scim/queries.tsx b/frontend/src/hooks/api/scim/queries.tsx new file mode 100644 index 000000000..b8a3291b1 --- /dev/null +++ b/frontend/src/hooks/api/scim/queries.tsx @@ -0,0 +1,24 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { GetScimTokenRes } from "./types"; + +const scimKeys = { + getScimToken: (orgId: string) => [{ orgId }, "organization-scim-token"] as const, +}; + +export const useGetScimToken = (organizationId: string) => { + return useQuery({ + queryKey: scimKeys.getScimToken(organizationId), + queryFn: async () => { + if (organizationId === "") { + return undefined; + } + + const { data: { scimToken } } = await apiRequest.get(`/api/v1/scim/token/organizations/${organizationId}`); + return scimToken; + }, + enabled: true + }); +}; \ No newline at end of file diff --git a/frontend/src/hooks/api/scim/types.ts b/frontend/src/hooks/api/scim/types.ts new file mode 100644 index 000000000..deec75ed0 --- /dev/null +++ b/frontend/src/hooks/api/scim/types.ts @@ -0,0 +1,3 @@ +export type GetScimTokenRes = { + scimToken: string; +}; \ No newline at end of file diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgAuthTab.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgAuthTab.tsx index c29c948fc..3141f2163 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgAuthTab.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgAuthTab.tsx @@ -1,6 +1,7 @@ import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; import { withPermission } from "@app/hoc"; +import { OrgSCIMSection } from "./OrgSCIMSection"; import { OrgSSOSection } from "./OrgSSOSection"; export const OrgAuthTab = withPermission( @@ -8,6 +9,7 @@ export const OrgAuthTab = withPermission( return (
+
); }, diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgSCIMSection.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgSCIMSection.tsx new file mode 100644 index 000000000..93c47ae3b --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgSCIMSection.tsx @@ -0,0 +1,108 @@ +import { useState } from "react"; +import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +// import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +// import { OrgPermissionCan } from "@app/components/permissions"; +import { + // Button, + IconButton, + Switch +} from "@app/components/v2"; +import { +// OrgPermissionActions, +// OrgPermissionSubjects, + useOrganization, +// useSubscription +} from "@app/context"; +import { useToggle } from "@app/hooks"; +// import { usePopUp } from "@app/hooks/usePopUp"; +import { useGetScimToken } from "@app/hooks/api"; + +// 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 [scimEnabled, setScimEnabled] = useState(false); // sync this with backend + const [isAPIKeyCopied, setIsAPIKeyCopied] = useToggle(false); + + // TODO: get SCIM stuf + + const handleSCIMToggle = (value: boolean) => { + // TODO + try { + setScimEnabled(value); + } catch (err) { + console.error(err); + } + } + + const copyTokenToClipboard = () => { + navigator.clipboard.writeText(scimToken ?? ""); + setIsAPIKeyCopied.on(); + }; + + return ( +
+

SCIM Configuration

+ handleSCIMToggle(value)} + isChecked={scimEnabled} + isDisabled={false} + > + 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 + + +
+ + )} +
+ )} +
+ ); +} \ No newline at end of file