Pass Okta SCIM 2.0 SPEC Test

This commit is contained in:
Tuan Dang
2024-02-14 09:22:23 -08:00
parent 3a7b697549
commit c73ee49425
29 changed files with 1098 additions and 136 deletions

View File

@@ -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;

View File

@@ -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,

View File

@@ -7,15 +7,15 @@ export async function up(knex: Knex): Promise<void> {
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<void> {

View File

@@ -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";

View File

@@ -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<typeof ScimTokensSchema>;
export type TScimTokensInsert = Omit<TScimTokens, TImmutableDBKeys>;
export type TScimTokensUpdate = Partial<Omit<TScimTokens, TImmutableDBKeys>>;

View File

@@ -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 };
}
});

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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<typeof scimDALFactory>;
export const scimDALFactory = (db: TDbClient) => {
const scimTokenOrm = ormify(db, TableName.ScimToken);
return scimTokenOrm;
};

View File

@@ -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<TPermissionServiceFactory, "getProjectPermission">;
scimDAL: TScimDALFactory; // TODO: pick
userDAL: TUserDALFactory; // TODO: pick
orgDAL: TOrgDALFactory; // TODO: pick
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
};
export type TScimServiceFactory = ReturnType<typeof scimServiceFactory>;
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<TListScimUsersRes> => {
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
};
};

View File

@@ -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<TOrgPermission, "orgId">;
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;
};

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,4 @@
export { TScimUser } from "./types";
export {
createScimUser
} from "./fns";

View File

@@ -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;
};
}

View File

@@ -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" });
}

View File

@@ -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:

View File

@@ -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 };
}
});
});

View File

@@ -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);
}

View File

@@ -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
});

View File

@@ -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 = {

View File

@@ -1,3 +1,5 @@
export {
useGetScimToken
} from "./queries";
export { useGetScimTokens } from "./queries";
export {
useCreateScimToken,
useDeleteScimToken
} from "./mutations";

View File

@@ -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<CreateScimTokenRes, {}, CreateScimTokenDTO>({
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<CreateScimTokenRes, {}, DeleteScimTokenDTO>({
mutationFn: async ({
organizationId,
scimTokenId
}) => {
const { data } = await apiRequest.delete(`/api/v1/scim/scim-tokens/${scimTokenId}`);
return data;
},
onSuccess: (_, { organizationId }) => {
queryClient.invalidateQueries(scimKeys.getScimTokens(organizationId));
}
});
};

View File

@@ -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<GetScimTokenRes>(`/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
});

View File

@@ -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;
};
}

View File

@@ -18,6 +18,7 @@ export type SubscriptionPlan = {
workspacesUsed: number;
environmentLimit: number;
samlSSO: boolean;
scim: boolean;
status:
| "incomplete"
| "incomplete_expired"

View File

@@ -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 (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
@@ -64,7 +59,7 @@ export const OrgScimSection = () => {
// isDisabled={!isAllowed}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add SCIM Token
Manage SCIM Tokens
</Button>
</div>
<Switch
@@ -75,48 +70,16 @@ export const OrgScimSection = () => {
>
Enable SCIM Provisioning
</Switch>
{scimEnabled && (
<div>
<div className="mt-8 mb-8">
<h3 className="text-sm text-mineshaft-400">SCIM URL</h3>
<p className="text-md text-gray-400">{`${window.origin}/api/v1/scim`}</p>
</div>
{/* <h3 className="mb-2 mt-8 text-sm text-mineshaft-400">SCIM URL</h3> */}
{/* <div className="mb-8 max-w-xl flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{`${window.origin}/api/v1/scim`}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={copyTokenToClipboard}
>
<FontAwesomeIcon icon={isAPIKeyCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Click to copy
</span>
</IconButton>
</div> */}
{scimToken && (
<>
<h3 className="mb-2 text-sm text-mineshaft-400">SCIM Bearer Token</h3>
<div className="max-w-xl flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{scimToken}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={copyTokenToClipboard}
>
<FontAwesomeIcon icon={isAPIKeyCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Click to copy
</span>
</IconButton>
</div>
</>
)}
</div>
)}
<ScimTokenModal
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can use SCIM Provisioning if you switch to Infisical's Pro plan."
/>
</div>
);
}

View File

@@ -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<typeof schema>;
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<FormData>({
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 (
<Modal
isOpen={popUp?.scimToken?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("scimToken", isOpen);
reset();
setToken("");
}}
>
<ModalContent title={`Manage SCIM credentials`}>
<h2 className="mb-4">SCIM URL</h2>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{scimUrl}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(scimUrl);
setIsScimUrlCopied.on();
}}
>
<FontAwesomeIcon icon={isScimUrlCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
{t("common.click-to-copy")}
</span>
</IconButton>
</div>
<h2 className="mb-4">New SCIM Token</h2>
{hasToken ? (
<div>
<div className="mb-4 flex items-center justify-between">
<p>We will only show this token once</p>
<Button
colorSchema="secondary"
type="submit"
onClick={() => {
reset();
setToken("");
}}
>
Got it
</Button>
</div>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{token}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(token);
setIsScimTokenCopied.on();
}}
>
<FontAwesomeIcon icon={isScimTokenCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
{t("common.click-to-copy")}
</span>
</IconButton>
</div>
</div>
) : (
<form onSubmit={handleSubmit(onFormSubmit)} className="mb-8">
<Controller
control={control}
defaultValue=""
name="description"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Description (optional)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Description" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="ttl"
render={({ field, fieldState: { error } }) => (
<FormControl
label="TTL (seconds - optional)"
isError={Boolean(error)}
errorText={error?.message}
>
<div className="flex">
<Input {...field} placeholder="0" type="number" min="0" step="1" />
<Button
className="ml-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Create
</Button>
</div>
</FormControl>
)}
/>
</form>
)}
<h2 className="mb-4">SCIM Tokens</h2>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Description</Th>
<Th>Expires At</Th>
<Th>Created At</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={4} innerKey="org-scim-tokens" />}
{!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 (
<Tr className="h-10 items-center" key={`mi-client-secret-${id}`}>
<Td>{description === "" ? "-" : description}</Td>
<Td>{expiresAt ? format(expiresAt, "yyyy-MM-dd") : "-"}</Td>
<Td>{format(new Date(createdAt), "yyyy-MM-dd HH:mm:ss")}</Td>
<Td>
<IconButton
onClick={() => {
handlePopUpOpen("deleteScimToken", {
scimTokenId: id
});
}}
size="lg"
colorSchema="primary"
variant="plain"
ariaLabel="update"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</Td>
</Tr>
);
}
)}
{!isLoading && data && data?.length === 0 && (
<Tr>
<Td colSpan={4}>
<EmptyState
title="No SCIM tokens have been created yet"
icon={faKey}
/>
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
<DeleteActionModal
isOpen={popUp.deleteScimToken.isOpen}
title={"Are you sure want to delete the SCIM token?"}
onChange={(isOpen) => handlePopUpToggle("scimToken", isOpen)}
deleteKey="confirm"
onDeleteApproved={() => {
const deleteScimTokenData = popUp?.deleteScimToken?.data as {
scimTokenId: string;
};
return onDeleteScimTokenSubmit(deleteScimTokenData.scimTokenId);
}}
/>
</ModalContent>
</Modal>
);
};