feat(infisical-pg: completed machine identity services

This commit is contained in:
Akhil Mohan
2023-12-30 19:32:03 +05:30
parent f5159583ae
commit d154f68a59
37 changed files with 1747 additions and 63 deletions

View File

@@ -16,6 +16,7 @@ module.exports = {
"consistent-return": "off", // my style
"import/order": "off", // for simple-import-order
"import/prefer-default-export": "off", // why
"no-restricted-syntax": "off",
"import/first": "error",
"import/newline-after-import": "error",
"import/no-duplicates": "error",

View File

@@ -13,6 +13,24 @@ import {
TBackupPrivateKey,
TBackupPrivateKeyInsert,
TBackupPrivateKeyUpdate,
TIdentities,
TIdentitiesInsert,
TIdentitiesUpdate,
TIdentityAccessTokens,
TIdentityAccessTokensInsert,
TIdentityAccessTokensUpdate,
TIdentityOrgMemberships,
TIdentityOrgMembershipsInsert,
TIdentityOrgMembershipsUpdate,
TIdentityProjectMemberships,
TIdentityProjectMembershipsInsert,
TIdentityProjectMembershipsUpdate,
TIdentityUaClientSecrets,
TIdentityUaClientSecretsInsert,
TIdentityUaClientSecretsUpdate,
TIdentityUniversalAuths,
TIdentityUniversalAuthsInsert,
TIdentityUniversalAuthsUpdate,
TIncidentContacts,
TIncidentContactsInsert,
TIncidentContactsUpdate,
@@ -208,6 +226,37 @@ declare module "knex/types/tables" {
TIntegrationAuthsInsert,
TIntegrationAuthsUpdate
>;
[TableName.Identity]: Knex.CompositeTableType<
TIdentities,
TIdentitiesInsert,
TIdentitiesUpdate
>;
[TableName.IdentityUniversalAuth]: Knex.CompositeTableType<
TIdentityUniversalAuths,
TIdentityUniversalAuthsInsert,
TIdentityUniversalAuthsUpdate
>;
[TableName.IdentityUaClientSecret]: Knex.CompositeTableType<
TIdentityUaClientSecrets,
TIdentityUaClientSecretsInsert,
TIdentityUaClientSecretsUpdate
>;
[TableName.IdentityAccessToken]: Knex.CompositeTableType<
TIdentityAccessTokens,
TIdentityAccessTokensInsert,
TIdentityAccessTokensUpdate
>;
[TableName.IdentityOrgMembership]: Knex.CompositeTableType<
TIdentityOrgMemberships,
TIdentityOrgMembershipsInsert,
TIdentityOrgMembershipsUpdate
>;
[TableName.IdentityProjectMembership]: Knex.CompositeTableType<
TIdentityProjectMemberships,
TIdentityProjectMembershipsInsert,
TIdentityProjectMembershipsUpdate
>;
// Junction tables
[TableName.JnSecretTag]: Knex.CompositeTableType<
TSecretTagJunction,
TSecretTagJunctionInsert,

View File

@@ -0,0 +1,21 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable(TableName.Identity))) {
await knex.schema.createTable(TableName.Identity, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("name").notNullable();
t.string("authMethod");
t.timestamps(true, true, true);
});
}
await createOnUpdateTrigger(knex, TableName.Identity);
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.Identity);
await dropOnUpdateTrigger(knex, TableName.Identity);
}

View File

@@ -0,0 +1,49 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable(TableName.IdentityUniversalAuth))) {
await knex.schema.createTable(TableName.IdentityUniversalAuth, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("clientId").notNullable();
t.integer("accessTokenTTL").defaultTo(7200).notNullable();
t.integer("accessTokenMaxTTL").defaultTo(7200).notNullable();
t.integer("accessTokenNumUsesLimit").defaultTo(0).notNullable();
t.jsonb("clientSecretTrustedIps").notNullable();
t.jsonb("accessTokenTrustedIps").notNullable();
t.timestamps(true, true, true);
t.uuid("identityId").notNullable().unique();
t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE");
});
}
if (!(await knex.schema.hasTable(TableName.IdentityUaClientSecret))) {
await knex.schema.createTable(TableName.IdentityUaClientSecret, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("description").notNullable();
t.string("clientSecretPrefix").notNullable();
t.string("clientSecretHash").notNullable();
t.datetime("clientSecretLastUsedAt");
t.integer("clientSecretNumUses").defaultTo(0).notNullable();
t.integer("clientSecretNumUsesLimit").defaultTo(0).notNullable();
t.integer("clientSecretTTL").defaultTo(0).notNullable();
t.boolean("isClientSecretRevoked").defaultTo(false).notNullable();
t.timestamps(true, true, true);
t.uuid("identityUAId").notNullable();
t.foreign("identityUAId")
.references("id")
.inTable(TableName.IdentityUniversalAuth)
.onDelete("CASCADE");
});
}
await createOnUpdateTrigger(knex, TableName.IdentityUniversalAuth);
await createOnUpdateTrigger(knex, TableName.IdentityUaClientSecret);
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.IdentityUaClientSecret);
await knex.schema.dropTableIfExists(TableName.IdentityUniversalAuth);
await dropOnUpdateTrigger(knex, TableName.IdentityUaClientSecret);
await dropOnUpdateTrigger(knex, TableName.IdentityUniversalAuth);
}

View File

@@ -0,0 +1,35 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable(TableName.IdentityAccessToken))) {
await knex.schema.createTable(TableName.IdentityAccessToken, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("authType").notNullable();
t.integer("accessTokenTTL").defaultTo(2592000).notNullable(); // 30 days second
t.integer("accessTokenMaxTTL").defaultTo(2592000).notNullable();
t.integer("accessTokenNumUses").defaultTo(0).notNullable();
t.integer("accessTokenNumUsesLimit").defaultTo(0).notNullable();
t.datetime("accessTokenLastUsedAt");
t.datetime("accessTokenLastRenewedAt");
t.boolean("isAccessTokenRevoked").defaultTo(false).notNullable();
t.uuid("identityUAClientSecretId");
t.foreign("identityUAClientSecretId")
.references("id")
.inTable(TableName.IdentityUaClientSecret)
.onDelete("CASCADE");
t.uuid("identityId").notNullable();
t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE");
t.timestamps(true, true, true);
});
}
await createOnUpdateTrigger(knex, TableName.IdentityAccessToken);
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.IdentityAccessToken);
await dropOnUpdateTrigger(knex, TableName.IdentityAccessToken);
}

View File

@@ -0,0 +1,44 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable(TableName.IdentityOrgMembership))) {
await knex.schema.createTable(TableName.IdentityOrgMembership, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("role").notNullable();
t.uuid("roleId");
t.foreign("roleId").references("id").inTable(TableName.OrgRoles);
t.uuid("orgId").notNullable();
t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE");
t.timestamps(true, true, true);
t.uuid("identityId").notNullable();
t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE");
});
}
await createOnUpdateTrigger(knex, TableName.IdentityOrgMembership);
if (!(await knex.schema.hasTable(TableName.IdentityProjectMembership))) {
await knex.schema.createTable(TableName.IdentityProjectMembership, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("role").notNullable();
t.uuid("roleId");
t.foreign("roleId").references("id").inTable(TableName.ProjectRoles);
t.uuid("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
t.uuid("identityId").notNullable();
t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE");
t.timestamps(true, true, true);
});
}
await createOnUpdateTrigger(knex, TableName.IdentityProjectMembership);
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.IdentityOrgMembership);
await knex.schema.dropTableIfExists(TableName.IdentityProjectMembership);
await dropOnUpdateTrigger(knex, TableName.IdentityProjectMembership);
await dropOnUpdateTrigger(knex, TableName.IdentityOrgMembership);
}

View File

@@ -0,0 +1,20 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const IdentitiesSchema = z.object({
id: z.string().uuid(),
name: z.string(),
authMethod: z.string().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date(),
});
export type TIdentities = z.infer<typeof IdentitiesSchema>;
export type TIdentitiesInsert = Omit<TIdentities, TImmutableDBKeys>;
export type TIdentitiesUpdate = Partial<Omit<TIdentities, TImmutableDBKeys>>;

View File

@@ -0,0 +1,28 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const IdentityAccessTokensSchema = z.object({
id: z.string().uuid(),
authType: z.string(),
accessTokenTTL: z.number().default(2592000),
accessTokenMaxTTL: z.number().default(2592000),
accessTokenNumUses: z.number().default(0),
accessTokenNumUsesLimit: z.number().default(0),
accessTokenLastUsedAt: z.date().nullable().optional(),
accessTokenLastRenewedAt: z.date().nullable().optional(),
isAccessTokenRevoked: z.boolean().default(false),
identityUAClientSecretId: z.string().uuid().nullable().optional(),
identityId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date(),
});
export type TIdentityAccessTokens = z.infer<typeof IdentityAccessTokensSchema>;
export type TIdentityAccessTokensInsert = Omit<TIdentityAccessTokens, TImmutableDBKeys>;
export type TIdentityAccessTokensUpdate = Partial<Omit<TIdentityAccessTokens, TImmutableDBKeys>>;

View File

@@ -0,0 +1,22 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const IdentityOrgMembershipsSchema = z.object({
id: z.string().uuid(),
role: z.string(),
roleId: z.string().uuid().nullable().optional(),
orgId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date(),
identityId: z.string().uuid(),
});
export type TIdentityOrgMemberships = z.infer<typeof IdentityOrgMembershipsSchema>;
export type TIdentityOrgMembershipsInsert = Omit<TIdentityOrgMemberships, TImmutableDBKeys>;
export type TIdentityOrgMembershipsUpdate = Partial<Omit<TIdentityOrgMemberships, TImmutableDBKeys>>;

View File

@@ -0,0 +1,22 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const IdentityProjectMembershipsSchema = z.object({
id: z.string().uuid(),
role: z.string(),
roleId: z.string().uuid().nullable().optional(),
projectId: z.string().uuid(),
identityId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date(),
});
export type TIdentityProjectMemberships = z.infer<typeof IdentityProjectMembershipsSchema>;
export type TIdentityProjectMembershipsInsert = Omit<TIdentityProjectMemberships, TImmutableDBKeys>;
export type TIdentityProjectMembershipsUpdate = Partial<Omit<TIdentityProjectMemberships, TImmutableDBKeys>>;

View File

@@ -0,0 +1,29 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const IdentityUaClientSecretsSchema = z.object({
id: z.string().uuid(),
description: z.string().nullable().optional(),
clientSecretPrefix: z.string(),
clientSecretHash: z.string(),
clientSecretLastUsedAt: z.date().nullable().optional(),
clientSecretNumUses: z.number().default(0),
clientSecretNumUsesLimit: z.number().default(0),
clientSecretTTL: z.number().default(0),
isClientSecretRevoked: z.boolean().default(false),
createdAt: z.date(),
updatedAt: z.date(),
identityUAId: z.string().uuid()
});
export type TIdentityUaClientSecrets = z.infer<typeof IdentityUaClientSecretsSchema>;
export type TIdentityUaClientSecretsInsert = Omit<TIdentityUaClientSecrets, TImmutableDBKeys>;
export type TIdentityUaClientSecretsUpdate = Partial<
Omit<TIdentityUaClientSecrets, TImmutableDBKeys>
>;

View File

@@ -0,0 +1,27 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const IdentityUniversalAuthsSchema = z.object({
id: z.string().uuid(),
clientId: z.string(),
accessTokenTTL: z.number().default(7200),
accessTokenMaxTTL: z.number().default(7200),
accessTokenNumUsesLimit: z.number().default(0),
clientSecretTrustedIps: z.unknown(),
accessTokenTrustedIps: z.unknown(),
createdAt: z.date(),
updatedAt: z.date(),
identityId: z.string().uuid()
});
export type TIdentityUniversalAuths = z.infer<typeof IdentityUniversalAuthsSchema>;
export type TIdentityUniversalAuthsInsert = Omit<TIdentityUniversalAuths, TImmutableDBKeys>;
export type TIdentityUniversalAuthsUpdate = Partial<
Omit<TIdentityUniversalAuths, TImmutableDBKeys>
>;

View File

@@ -2,6 +2,12 @@ export * from "./api-keys";
export * from "./auth-token-sessions";
export * from "./auth-tokens";
export * from "./backup-private-key";
export * from "./identities";
export * from "./identity-access-tokens";
export * from "./identity-org-memberships";
export * from "./identity-project-memberships";
export * from "./identity-ua-client-secrets";
export * from "./identity-universal-auths";
export * from "./incident-contacts";
export * from "./integration-auths";
export * from "./integrations";

View File

@@ -29,6 +29,12 @@ export enum TableName {
IntegrationAuth = "integration_auths",
ServiceToken = "service_tokens",
Webhook = "webhooks",
Identity = "identities",
IdentityAccessToken = "identity_access_tokens",
IdentityUniversalAuth = "identity_universal_auths",
IdentityUaClientSecret = "identity_ua_client_secrets",
IdentityOrgMembership = "identity_org_memberships",
IdentityProjectMembership = " identity_project_memberships",
JnSecretTag = "secret_tag_junction",
JnSecretVersionTag = "secret_version_tag_junction"
}
@@ -53,6 +59,7 @@ export const ServiceTokenScopes = z
export enum OrgMembershipRole {
Admin = "admin",
Member = "member",
NoAccess = "no-access",
Custom = "custom"
}
@@ -83,3 +90,7 @@ export enum SecretType {
Shared = "shared",
Personal = "personal"
}
export enum IdentityAuthMethod {
Univeral = "universal"
}

View File

@@ -1,28 +1,6 @@
import {
AbilityBuilder,
buildMongoQueryMatcher,
createMongoAbility,
MongoAbility
} from "@casl/ability";
import { FieldCondition, FieldInstruction, JsInterpreter } from "@ucast/mongo2js";
import picomatch from "picomatch";
import { AbilityBuilder, createMongoAbility, MongoAbility } from "@casl/ability";
const $glob: FieldInstruction<string> = {
type: "field",
validate(instruction, value) {
if (typeof value !== "string") {
throw new Error(`"${instruction.name}" expects value to be a string`);
}
}
};
const glob: JsInterpreter<FieldCondition<string>> = (node, object, context) => {
const secretPath = context.get(object, node.field);
const permissionSecretGlobPath = node.value;
return picomatch.isMatch(secretPath, permissionSecretGlobPath, { strictSlashes: false });
};
export const conditionsMatcher = buildMongoQueryMatcher({ $glob }, { glob });
import { conditionsMatcher } from "@app/lib/casl";
export enum OrgPermissionActions {
Read = "read",
@@ -39,7 +17,8 @@ export enum OrgPermissionSubjects {
IncidentAccount = "incident-contact",
Sso = "sso",
Billing = "billing",
SecretScanning = "secret-scanning"
SecretScanning = "secret-scanning",
Identity = "identity"
}
export type OrgPermissionSet =
@@ -51,7 +30,8 @@ export type OrgPermissionSet =
| [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount]
| [OrgPermissionActions, OrgPermissionSubjects.Sso]
| [OrgPermissionActions, OrgPermissionSubjects.SecretScanning]
| [OrgPermissionActions, OrgPermissionSubjects.Billing];
| [OrgPermissionActions, OrgPermissionSubjects.Billing]
| [OrgPermissionActions, OrgPermissionSubjects.Identity];
const buildAdminPermission = () => {
const { can, build } = new AbilityBuilder<MongoAbility<OrgPermissionSet>>(createMongoAbility);
@@ -94,6 +74,11 @@ const buildAdminPermission = () => {
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing);
can(OrgPermissionActions.Delete, OrgPermissionSubjects.Billing);
can(OrgPermissionActions.Read, OrgPermissionSubjects.Identity);
can(OrgPermissionActions.Create, OrgPermissionSubjects.Identity);
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity);
can(OrgPermissionActions.Delete, OrgPermissionSubjects.Identity);
return build({ conditionsMatcher });
};
@@ -117,7 +102,19 @@ const buildMemberPermission = () => {
can(OrgPermissionActions.Edit, OrgPermissionSubjects.SecretScanning);
can(OrgPermissionActions.Delete, OrgPermissionSubjects.SecretScanning);
can(OrgPermissionActions.Read, OrgPermissionSubjects.Identity);
can(OrgPermissionActions.Create, OrgPermissionSubjects.Identity);
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity);
can(OrgPermissionActions.Delete, OrgPermissionSubjects.Identity);
return build({ conditionsMatcher });
};
export const orgMemberPermissions = buildMemberPermission();
const buildNoAccessPermission = () => {
const { build } = new AbilityBuilder<MongoAbility<OrgPermissionSet>>(createMongoAbility);
return build({ conditionsMatcher });
};
export const orgNoAccessPermissions = buildNoAccessPermission();

View File

@@ -1,14 +1,17 @@
import { createMongoAbility, MongoAbility, RawRuleOf } from "@casl/ability";
import { unpackRules } from "@casl/ability/extra";
import { PackRule, unpackRules } from "@casl/ability/extra";
import { ProjectMembershipRole } from "@app/db/schemas";
import { OrgMembershipRole, ProjectMembershipRole } from "@app/db/schemas";
import { conditionsMatcher } from "@app/lib/casl";
import { BadRequestError, UnauthorizedError } from "@app/lib/errors";
import { ActorType } from "@app/services/auth/auth-type";
import { TOrgRoleDalFactory } from "@app/services/org/org-role-dal";
import { TProjectRoleDalFactory } from "@app/services/project-role/project-role-dal";
import {
conditionsMatcher,
orgAdminPermissions,
orgMemberPermissions,
orgNoAccessPermissions,
OrgPermissionSet
} from "./org-permission";
import { TPermissionDalFactory } from "./permission-dal";
@@ -21,12 +24,18 @@ import {
} from "./project-permission";
type TPermissionServiceFactoryDep = {
orgRoleDal: Pick<TOrgRoleDalFactory, "findOne">;
projectRoleDal: Pick<TProjectRoleDalFactory, "findOne">;
permissionDal: TPermissionDalFactory;
};
export type TPermissionServiceFactory = ReturnType<typeof permissionServiceFactory>;
export const permissionServiceFactory = ({ permissionDal }: TPermissionServiceFactoryDep) => {
export const permissionServiceFactory = ({
permissionDal,
orgRoleDal,
projectRoleDal
}: TPermissionServiceFactoryDep) => {
/*
* Get user permission in an organization
* */
@@ -37,9 +46,14 @@ export const permissionServiceFactory = ({ permissionDal }: TPermissionServiceFa
throw new BadRequestError({ name: "Custom permission not found" });
}
if (membership.role === "admin") return { permission: orgAdminPermissions, membership };
if (membership.role === "member") return { permission: orgMemberPermissions, membership };
if (membership.role === "custom") {
if (membership.role === OrgMembershipRole.Admin)
return { permission: orgAdminPermissions, membership };
if (membership.role === OrgMembershipRole.Member)
return { permission: orgMemberPermissions, membership };
if (membership.role === OrgMembershipRole.NoAccess)
return { permission: orgNoAccessPermissions, membership };
if (membership.role === OrgMembershipRole.Custom) {
const permission = createMongoAbility<OrgPermissionSet>(
// akhilmhdh: putting any due to ts incompatiable matching with string and the other
unpackRules<RawRuleOf<MongoAbility<OrgPermissionSet>>>(membership.permissions as any),
@@ -57,7 +71,7 @@ export const permissionServiceFactory = ({ permissionDal }: TPermissionServiceFa
const getUserProjectPermission = async (userId: string, projectId: string) => {
const membership = await permissionDal.getProjectPermission(userId, projectId);
if (!membership) throw new UnauthorizedError({ name: "User not in org" });
if (membership.role === "custom" && !membership.permissions) {
if (membership.role === ProjectMembershipRole.Custom && !membership.permissions) {
throw new BadRequestError({ name: "Custom permission not found" });
}
@@ -107,10 +121,72 @@ export const permissionServiceFactory = ({ permissionDal }: TPermissionServiceFa
}
};
const getOrgPermissionByRole = async (role: string, orgId: string) => {
const isCustomRole = !Object.values(OrgMembershipRole).includes(role as OrgMembershipRole);
if (isCustomRole) {
const orgRole = await orgRoleDal.findOne({ slug: role, orgId });
if (!orgRole) throw new BadRequestError({ message: "Role not found" });
return {
permission: createMongoAbility<OrgPermissionSet>(
unpackRules<RawRuleOf<MongoAbility<OrgPermissionSet>>>(
(orgRole.permissions as PackRule<RawRuleOf<MongoAbility<OrgPermissionSet>>>[]) || []
),
{
conditionsMatcher
}
),
role: orgRole
};
}
switch (role) {
case OrgMembershipRole.Admin:
return { permission: orgAdminPermissions };
case OrgMembershipRole.Member:
return { permission: orgMemberPermissions };
case OrgMembershipRole.NoAccess:
return { permission: orgNoAccessPermissions };
default:
throw new BadRequestError({ message: "Org role not found" });
}
};
const getProjectPermissionByRole = async (role: string, projectId: string) => {
const isCustomRole = !Object.values(OrgMembershipRole).includes(role as OrgMembershipRole);
if (isCustomRole) {
const projectRole = await projectRoleDal.findOne({ slug: role, projectId });
if (!projectRole) throw new BadRequestError({ message: "Role not found" });
return {
permission: createMongoAbility<ProjectPermissionSet>(
unpackRules<RawRuleOf<MongoAbility<ProjectPermissionSet>>>(
(projectRole.permissions as PackRule<
RawRuleOf<MongoAbility<ProjectPermissionSet>>
>[]) || []
),
{
conditionsMatcher
}
),
role: projectRole
};
}
switch (role) {
case ProjectMembershipRole.Admin:
return { permission: projectAdminPermissions };
case ProjectMembershipRole.Member:
return { permission: projectMemberPermissions };
case ProjectMembershipRole.NoAccess:
return { permission: projectNoAccessPermissions };
default:
throw new BadRequestError({ message: "Org role not found" });
}
};
return {
getUserOrgPermission,
getOrgPermission,
getUserProjectPermission,
getProjectPermission
getProjectPermission,
getOrgPermissionByRole,
getProjectPermissionByRole
};
};

View File

@@ -1,28 +1,6 @@
import {
AbilityBuilder,
buildMongoQueryMatcher,
createMongoAbility,
ForcedSubject,
MongoAbility} from "@casl/ability";
import { FieldCondition, FieldInstruction, JsInterpreter } from "@ucast/mongo2js";
import picomatch from "picomatch";
import { AbilityBuilder, createMongoAbility, ForcedSubject, MongoAbility } from "@casl/ability";
const $glob: FieldInstruction<string> = {
type: "field",
validate(instruction, value) {
if (typeof value !== "string") {
throw new Error(`"${instruction.name}" expects value to be a string`);
}
}
};
const glob: JsInterpreter<FieldCondition<string>> = (node, object, context) => {
const secretPath = context.get(object, node.field);
const permissionSecretGlobPath = node.value;
return picomatch.isMatch(secretPath, permissionSecretGlobPath, { strictSlashes: false });
};
export const conditionsMatcher = buildMongoQueryMatcher({ $glob }, { glob });
import { conditionsMatcher } from "@app/lib/casl";
export enum ProjectPermissionActions {
Read = "read",

View File

@@ -0,0 +1,44 @@
import { buildMongoQueryMatcher, MongoAbility } from "@casl/ability";
import { FieldCondition, FieldInstruction, JsInterpreter } from "@ucast/mongo2js";
import picomatch from "picomatch";
const $glob: FieldInstruction<string> = {
type: "field",
validate(instruction, value) {
if (typeof value !== "string") {
throw new Error(`"${instruction.name}" expects value to be a string`);
}
}
};
const glob: JsInterpreter<FieldCondition<string>> = (node, object, context) => {
const secretPath = context.get(object, node.field);
const permissionSecretGlobPath = node.value;
return picomatch.isMatch(secretPath, permissionSecretGlobPath, { strictSlashes: false });
};
export const conditionsMatcher = buildMongoQueryMatcher({ $glob }, { glob });
/**
* Extracts and formats permissions from a CASL Ability object or a raw permission set.
*/
const extractPermissions = (ability: MongoAbility) =>
ability.rules.map((permission) => `${permission.action}_${permission.subject}`);
/**
* Compares two sets of permissions to determine if the first set is at least as privileged as the second set.
* The function checks if all permissions in the second set are contained within the first set and if the first set has equal or more permissions.
*
*/
export const isAtLeastAsPrivileged = (permissions1: MongoAbility, permissions2: MongoAbility) => {
const set1 = new Set(extractPermissions(permissions1));
const set2 = new Set(extractPermissions(permissions2));
for (const perm of set2) {
if (!set1.has(perm)) {
return false;
}
}
return set1.size >= set2.size;
};

View File

@@ -16,9 +16,21 @@ export class UnauthorizedError extends Error {
error: unknown;
constructor({ name, error, message }: { message?: string; name: string; error?: unknown }) {
constructor({ name, error, message }: { message?: string; name?: string; error?: unknown } = {}) {
super(message ?? "You are not allowed to access this resourve");
this.name = name;
this.name = name || "UnauthorizedError";
this.error = error;
}
}
export class ForbiddenRequestError extends Error {
name: string;
error: unknown;
constructor({ name, error, message }: { message?: string; name?: string; error?: unknown }) {
super(message ?? "You are not allowed to access this resourve");
this.name = name || "ForbideenError";
this.error = error;
}
}

View File

@@ -0,0 +1,137 @@
import net from "node:net";
import { UnauthorizedError } from "../errors";
export enum IPType {
IPV4 = "ipv4",
IPV6 = "ipv6"
}
/**
* Return details of IP [ip]:
* - If [ip] is a specific IP address then return the IPv4/IPv6 address
* - If [ip] is a subnet then return the network IPv4/IPv6 address and prefix
*/
export const extractIPDetails = (ip: string) => {
if (net.isIPv4(ip))
return {
ipAddress: ip,
type: IPType.IPV4
};
if (net.isIPv6(ip))
return {
ipAddress: ip,
type: IPType.IPV6
};
const [ipNet, prefix] = ip.split("/");
let type;
switch (net.isIP(ipNet)) {
case 4:
type = IPType.IPV4;
break;
case 6:
type = IPType.IPV6;
break;
default:
throw new Error("Failed to extract IP details");
}
return {
ipAddress: ipNet,
type,
prefix: parseInt(prefix, 10)
};
};
/**
* Checks if a given string is a valid CIDR block.
*
* The function checks if the input string is a valid IPv4 or IPv6 address in CIDR notation.
*
* CIDR notation includes a network address followed by a slash ('/') and a prefix length.
* For IPv4, the prefix length must be between 0 and 32. For IPv6, it must be between 0 and 128.
* If the input string is not a valid CIDR block, the function returns `false`.
*
*/
export const isValidCidr = (cidr: string): boolean => {
const [ip, prefix] = cidr.split("/");
const prefixNum = parseInt(prefix, 10);
// ensure prefix exists and is a number within the appropriate range for each IP version
if (
!prefix ||
Number.isNaN(prefixNum) ||
(net.isIPv4(ip) && (prefixNum < 0 || prefixNum > 32)) ||
(net.isIPv6(ip) && (prefixNum < 0 || prefixNum > 128))
) {
return false;
}
// ensure the IP portion of the CIDR block is a valid IPv4 or IPv6 address
if (!net.isIPv4(ip) && !net.isIPv6(ip)) {
return false;
}
return true;
};
/**
* Checks if a given string is a valid IPv4/IPv6 address or a valid CIDR block.
*
* If the string contains a slash ('/'), it treats the input as a CIDR block and checks its validity.
* Otherwise, it treats the string as a standalone IP address (either IPv4 or IPv6) and checks its validity.
*
* @param {string} input - The string to be checked. It could be an IP address or a CIDR block.
* @returns {boolean} Returns `true` if the string is a valid IP address (either IPv4 or IPv6) or a valid CIDR block, `false` otherwise.
*
*/
export const isValidIpOrCidr = (ip: string): boolean => {
// if the string contains a slash, treat it as a CIDR block
if (ip.includes("/")) {
return isValidCidr(ip);
}
// otherwise, treat it as a standalone IP address
if (net.isIPv4(ip) || net.isIPv6(ip)) {
return true;
}
return false;
};
/**
* Validates the IP address [ipAddress] against the trusted IPs [trustedIps].
*/
export const checkIPAgainstBlocklist = ({
ipAddress,
trustedIps
}: {
ipAddress: string;
trustedIps: {
ipAddress: string;
type: IPType;
prefix: number;
}[];
}) => {
const blockList = new net.BlockList();
for (const trustedIp of trustedIps) {
if (trustedIp.prefix !== undefined) {
blockList.addSubnet(trustedIp.ipAddress, trustedIp.prefix, trustedIp.type);
} else {
blockList.addAddress(trustedIp.ipAddress, trustedIp.type);
}
}
const { type } = extractIPDetails(ipAddress);
const check = blockList.check(ipAddress, type);
if (!check)
throw new UnauthorizedError({
message: "Failed to authenticate"
});
};

View File

@@ -1,5 +1,11 @@
import { ActorType } from "@app/services/auth/auth-type";
export type TOrgPermission = {
actor: ActorType;
actorId: string;
orgId: string;
};
export type TProjectPermission = {
actor: ActorType;
actorId: string;

View File

@@ -90,7 +90,7 @@ export const registerRoutes = async (
const permissionDal = permissionDalFactory(db);
// ee services
const permissionService = permissionServiceFactory({ permissionDal });
const permissionService = permissionServiceFactory({ permissionDal, orgRoleDal, projectRoleDal });
// service layers
const tokenService = tokenServiceFactory({ tokenDal: authTokenDal });

View File

@@ -16,7 +16,8 @@ export enum AuthTokenType {
PROVIDER_TOKEN = "providerToken", // TODO: remove in favor of claim
API_KEY = "apiKey",
SERVICE_ACCESS_TOKEN = "serviceAccessToken",
SERVICE_REFRESH_TOKEN = "serviceRefreshToken"
SERVICE_REFRESH_TOKEN = "serviceRefreshToken",
IDENTITY_ACCESS_TOKEN = "identityAccessToken"
}
export enum AuthMode {

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 TIdentityAccessTokenDalFactory = ReturnType<typeof identityAccessTokenDalFactory>;
export const identityAccessTokenDalFactory = (db: TDbClient) => {
const identityAccessTokenOrm = ormify(db, TableName.IdentityAccessToken);
return identityAccessTokenOrm;
};

View File

@@ -0,0 +1,93 @@
import jwt, { JwtPayload } from "jsonwebtoken";
import { getConfig } from "@app/lib/config/env";
import { UnauthorizedError } from "@app/lib/errors";
import { AuthTokenType } from "../auth/auth-type";
import { TIdentityAccessTokenDalFactory } from "./identity-access-token-dal";
import { TRenewAccessTokenDTO } from "./identity-access-token-types";
type TIdentityAccessTokenServiceFactoryDep = {
identityAccessTokenDal: TIdentityAccessTokenDalFactory;
};
export type TIdentityAccessTokenServiceFactory = ReturnType<
typeof identityAccessTokenServiceFactory
>;
export const identityAccessTokenServiceFactory = ({
identityAccessTokenDal
}: TIdentityAccessTokenServiceFactoryDep) => {
const renewAccessToken = async ({ accessToken }: TRenewAccessTokenDTO) => {
const appCfg = getConfig();
const decodedToken = jwt.verify(accessToken, appCfg.JWT_AUTH_SECRET) as JwtPayload;
if (decodedToken.authTokenType !== AuthTokenType.IDENTITY_ACCESS_TOKEN)
throw new UnauthorizedError();
const identityAccessToken = await identityAccessTokenDal.findOne({
id: decodedToken.identityAccessTokenId,
isAccessTokenRevoked: false
});
if (!identityAccessToken) throw new UnauthorizedError();
const {
accessTokenTTL,
accessTokenLastRenewedAt,
accessTokenMaxTTL,
createdAt: accessTokenCreatedAt
} = identityAccessToken;
// ttl check
if (accessTokenTTL > 0) {
const currentDate = new Date();
if (accessTokenLastRenewedAt) {
// access token has been renewed
const accessTokenRenewed = new Date(accessTokenLastRenewedAt);
const ttlInMilliseconds = accessTokenTTL * 1000;
const expirationDate = new Date(accessTokenRenewed.getTime() + ttlInMilliseconds);
if (currentDate > expirationDate)
throw new UnauthorizedError({
message: "Failed to renew MI access token due to TTL expiration"
});
} else {
// access token has never been renewed
const accessTokenCreated = new Date(accessTokenCreatedAt);
const ttlInMilliseconds = accessTokenTTL * 1000;
const expirationDate = new Date(accessTokenCreated.getTime() + ttlInMilliseconds);
if (currentDate > expirationDate)
throw new UnauthorizedError({
message: "Failed to renew MI access token due to TTL expiration"
});
}
}
// max ttl checks
if (accessTokenMaxTTL > 0) {
const accessTokenCreated = new Date(accessTokenCreatedAt);
const ttlInMilliseconds = accessTokenMaxTTL * 1000;
const currentDate = new Date();
const expirationDate = new Date(accessTokenCreated.getTime() + ttlInMilliseconds);
if (currentDate > expirationDate)
throw new UnauthorizedError({
message: "Failed to renew MI access token due to Max TTL expiration"
});
const extendToDate = new Date(currentDate.getTime() + accessTokenTTL);
if (extendToDate > expirationDate)
throw new UnauthorizedError({
message: "Failed to renew MI access token past its Max TTL expiration"
});
}
await identityAccessTokenDal.updateById(identityAccessToken.id, {
accessTokenLastRenewedAt: new Date()
});
return identityAccessTokenDal;
};
return { renewAccessToken };
};

View File

@@ -0,0 +1,3 @@
export type TRenewAccessTokenDTO = {
accessToken: string;
};

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 TIdentityProjectDalFactory = ReturnType<typeof identityProjectDalFactory>;
export const identityProjectDalFactory = (db: TDbClient) => {
const identityProjectOrm = ormify(db, TableName.IdentityProjectMembership);
return identityProjectOrm;
};

View File

@@ -0,0 +1,186 @@
import { ForbiddenError } from "@casl/ability";
import { ProjectMembershipRole, TProjectRoles } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import {
ProjectPermissionActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { isAtLeastAsPrivileged } from "@app/lib/casl";
import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
import { ActorType } from "../auth/auth-type";
import { TIdentityOrgDalFactory } from "../identity/identity-org-dal";
import { TProjectDalFactory } from "../project/project-dal";
import { TIdentityProjectDalFactory } from "./identity-project-dal";
import {
TCreateProjectIdentityDTO,
TDeleteProjectIdentityDTO,
TListProjectIdentityDTO,
TUpdateProjectIdentityDTO
} from "./identity-project-types";
type TIdentityProjectServiceFactoryDep = {
identityProjectDal: TIdentityProjectDalFactory;
projectDal: Pick<TProjectDalFactory, "findById">;
identityOrgMembershipDal: Pick<TIdentityOrgDalFactory, "findOne">;
permissionService: Pick<
TPermissionServiceFactory,
"getProjectPermission" | "getProjectPermissionByRole"
>;
};
export type TIdentityProjectServiceFactory = ReturnType<typeof identityProjectServiceFactory>;
export const identityProjectServiceFactory = ({
identityProjectDal,
permissionService,
identityOrgMembershipDal,
projectDal
}: TIdentityProjectServiceFactoryDep) => {
const createProjectIdentity = async ({
identityId,
actor,
actorId,
projectId,
role
}: TCreateProjectIdentityDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create,
ProjectPermissionSub.Identity
);
const existingIdentity = await identityProjectDal.findOne({ identityId, projectId });
if (existingIdentity)
throw new BadRequestError({
message: `Identity with id ${identityId} already exists in project with id ${projectId}`
});
const project = await projectDal.findById(projectId);
const identityOrgMembership = await identityOrgMembershipDal.findOne({
identityId,
orgId: project.orgId
});
if (!identityOrgMembership)
throw new BadRequestError({
message: `Failed to find identity with id ${identityId}`
});
const { permission: rolePermission, role: customRole } =
await permissionService.getProjectPermissionByRole(role, project.id);
const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission);
if (!hasPriviledge)
throw new ForbiddenRequestError({
message: "Failed to add identity to project with more privileged role"
});
const isCustomRole = Boolean(customRole);
const projectIdentity = await identityProjectDal.create({
identityId,
projectId: project.id,
role: isCustomRole ? ProjectMembershipRole.Custom : role,
roleId: customRole?.id
});
return projectIdentity;
};
const updateProjectIdentity = async ({
projectId,
identityId,
role,
actor,
actorId
}: TUpdateProjectIdentityDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit,
ProjectPermissionSub.Identity
);
const projectIdentity = await identityProjectDal.findOne({ identityId, projectId });
if (!projectIdentity)
throw new BadRequestError({
message: `Identity with id ${identityId} doesn't exists in project with id ${projectId}`
});
const { permission: identityRolePermission } = await permissionService.getProjectPermission(
ActorType.IDENTITY,
projectIdentity.identityId,
projectIdentity.projectId
);
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission);
if (!hasRequiredPriviledges)
throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" });
let customRole: TProjectRoles | undefined;
if (role) {
const { permission: rolePermission, role: customOrgRole } =
await permissionService.getProjectPermissionByRole(role, projectIdentity.projectId);
const isCustomRole = Boolean(customOrgRole);
const hasRequiredNewRolePermission = isAtLeastAsPrivileged(permission, rolePermission);
if (!hasRequiredNewRolePermission)
throw new BadRequestError({ message: "Failed to create a more privileged identity" });
if (isCustomRole) customRole = customOrgRole;
}
const [updatedProjectIdentity] = await identityProjectDal.update(
{ projectId, identityId: projectIdentity.identityId },
{
role: customRole ? ProjectMembershipRole.Custom : role,
roleId: customRole ? customRole.id : null
}
);
return updatedProjectIdentity;
};
const deleteProjectIdentity = async ({
identityId,
actorId,
actor
}: TDeleteProjectIdentityDTO) => {
const identityProjectMembership = await identityProjectDal.findById(identityId);
if (!identityProjectMembership)
throw new BadRequestError({ message: `Failed to find identity with id ${identityId}` });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
identityProjectMembership.projectId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Delete,
ProjectPermissionSub.Identity
);
const { permission: identityRolePermission } = await permissionService.getProjectPermission(
ActorType.IDENTITY,
identityId,
identityProjectMembership.projectId
);
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission);
if (!hasRequiredPriviledges)
throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" });
const [deletedIdentity] = await identityProjectDal.delete({ identityId });
return deletedIdentity;
};
const listProjectIdentities = async ({ projectId, actor, actorId }: TListProjectIdentityDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
ProjectPermissionSub.Identity
);
const identityMemberhips = await identityProjectDal.find({ projectId });
return identityMemberhips;
};
return {
createProjectIdentity,
updateProjectIdentity,
deleteProjectIdentity,
listProjectIdentities
};
};

View File

@@ -0,0 +1,17 @@
import { TProjectPermission } from "@app/lib/types";
export type TCreateProjectIdentityDTO = {
identityId: string;
role: string;
} & TProjectPermission;
export type TUpdateProjectIdentityDTO = {
role: string;
identityId: string;
} & TProjectPermission;
export type TDeleteProjectIdentityDTO = {
identityId: string;
} & Omit<TProjectPermission, "projectId">;
export type TListProjectIdentityDTO = TProjectPermission;

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 TIdentityDalFactory = ReturnType<typeof identityDalFactory>;
export const identityDalFactory = (db: TDbClient) => {
const identityOrm = ormify(db, TableName.Identity);
return identityOrm;
};

View File

@@ -0,0 +1,35 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName,TIdentityOrgMemberships } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex";
export type TIdentityOrgDalFactory = ReturnType<typeof identityOrgDalFactory>;
export const identityOrgDalFactory = (db: TDbClient) => {
const identityOrgOrm = ormify(db, TableName.IdentityOrgMembership);
const findOne = async (filter: Partial<TIdentityOrgMemberships>, tx?: Knex) => {
try {
const [data] = await (tx || db)(TableName.IdentityOrgMembership)
.where(filter)
.join(
TableName.Identity,
`${TableName.IdentityOrgMembership}.identityId`,
`${TableName.Identity}.id`
)
.select(selectAllTableCols(TableName.IdentityOrgMembership))
.select(db.ref("name").withSchema(TableName.Identity))
.select(db.ref("authMethod").withSchema(TableName.Identity));
if (data) {
const { name, authMethod } = data;
return { ...data, identity: { id: data.identityId, name, authMethod } };
}
} catch (error) {
throw new DatabaseError({ error, name: "FindOne" });
}
};
return { ...identityOrgOrm, findOne };
};

View File

@@ -0,0 +1,149 @@
import { ForbiddenError } from "@casl/ability";
import { OrgMembershipRole, TOrgRoles } from "@app/db/schemas";
import {
OrgPermissionActions,
OrgPermissionSubjects
} from "@app/ee/services/permission/org-permission";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { isAtLeastAsPrivileged } from "@app/lib/casl";
import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
import { ActorType } from "../auth/auth-type";
import { TIdentityDalFactory } from "./identity-dal";
import { TIdentityOrgDalFactory } from "./identity-org-dal";
import { TCreateIdentityDTO, TDeleteIdentityDTO, TUpdateIdentityDTO } from "./identity-types";
type TIdentityServiceFactoryDep = {
identityDal: TIdentityDalFactory;
identityOrgDal: TIdentityOrgDalFactory;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission" | "getOrgPermissionByRole">;
};
export type TIdentityServiceFactory = ReturnType<typeof identityServiceFactory>;
export const identityServiceFactory = ({
identityDal,
identityOrgDal,
permissionService
}: TIdentityServiceFactoryDep) => {
const createIdentity = async ({ name, role, actor, orgId, actorId }: TCreateIdentityDTO) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionActions.Create,
OrgPermissionSubjects.Identity
);
const { permission: rolePermission, role: customRole } =
await permissionService.getOrgPermissionByRole(role, orgId);
const isCustomRole = Boolean(customRole);
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, rolePermission);
if (!hasRequiredPriviledges)
throw new BadRequestError({ message: "Failed to create a more privileged identity" });
const identity = await identityDal.transaction(async (tx) => {
const newIdentity = await identityDal.create({ name }, tx);
await identityOrgDal.create(
{
identityId: newIdentity.id,
orgId,
role: isCustomRole ? OrgMembershipRole.Custom : role,
roleId: customRole?.id
},
tx
);
return newIdentity;
});
// TODO(akhilmhdh-pg): add audit log here
return identity;
};
const updateIdentity = async ({ id, role, name, actor, actorId }: TUpdateIdentityDTO) => {
const identityOrgMembership = await identityOrgDal.findById(id);
if (!identityOrgMembership)
throw new BadRequestError({ message: `Failed to find identity with id ${id}` });
const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
identityOrgMembership.orgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionActions.Edit,
OrgPermissionSubjects.Identity
);
const { permission: identityRolePermission } = await permissionService.getOrgPermission(
ActorType.IDENTITY,
id,
identityOrgMembership.orgId
);
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission);
if (!hasRequiredPriviledges)
throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" });
let customRole: TOrgRoles | undefined;
if (role) {
const { permission: rolePermission, role: customOrgRole } =
await permissionService.getOrgPermissionByRole(role, identityOrgMembership.orgId);
const isCustomRole = Boolean(customOrgRole);
const hasRequiredNewRolePermission = isAtLeastAsPrivileged(permission, rolePermission);
if (!hasRequiredNewRolePermission)
throw new BadRequestError({ message: "Failed to create a more privileged identity" });
if (isCustomRole) customRole = customOrgRole;
}
const identity = await identityDal.transaction(async (tx) => {
const newIdentity = await identityDal.updateById(id, { name }, tx);
if (role) {
await identityOrgDal.update(
{ identityId: id },
{
role: customRole ? OrgMembershipRole.Custom : role,
roleId: customRole?.id
},
tx
);
}
return newIdentity;
});
// TODO(akhilmhdh-pg): add audit log here
return identity;
};
const deleteIdentity = async ({ actorId, actor, id }: TDeleteIdentityDTO) => {
const identityOrgMembership = await identityOrgDal.findById(id);
if (!identityOrgMembership)
throw new BadRequestError({ message: `Failed to find identity with id ${id}` });
const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
identityOrgMembership.orgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionActions.Delete,
OrgPermissionSubjects.Identity
);
const { permission: identityRolePermission } = await permissionService.getOrgPermission(
ActorType.IDENTITY,
id,
identityOrgMembership.orgId
);
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission);
if (!hasRequiredPriviledges)
throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" });
const deletedIdentity = await identityDal.deleteById(id);
return deletedIdentity;
};
return {
createIdentity,
updateIdentity,
deleteIdentity
};
};

View File

@@ -0,0 +1,16 @@
import { TOrgPermission } from "@app/lib/types";
export type TCreateIdentityDTO = {
role: string;
name: string;
} & TOrgPermission;
export type TUpdateIdentityDTO = {
id: string;
role: string;
name: string;
} & Omit<TOrgPermission, "orgId">;
export type TDeleteIdentityDTO = {
id: string;
} & Omit<TOrgPermission, "orgId">;

View File

@@ -0,0 +1,27 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex";
export type TUaClientSecretDalFactory = ReturnType<typeof uaClientSecretDalFactory>;
export const uaClientSecretDalFactory = (db: TDbClient) => {
const uaClientSecretOrm = ormify(db, TableName.IdentityUaClientSecret);
const incrementUsage = async (id: string, tx?: Knex) => {
try {
const [doc] = await (tx || db)(TableName.IdentityUaClientSecret)
.where({ id })
.update({ clientSecretLastUsedAt: new Date() })
.increment("clientSecretNumUses", 1)
.returning("*");
return doc;
} catch (error) {
throw new DatabaseError({ error, name: "IncrementUsage" });
}
};
return { ...uaClientSecretOrm, incrementUsage };
};

View File

@@ -0,0 +1,11 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TUniversalAuthDalFactory = ReturnType<typeof universalAuthDalFactory>;
export const universalAuthDalFactory = (db: TDbClient) => {
const universalAuthOrm = ormify(db, TableName.IdentityUniversalAuth);
return universalAuthOrm;
};

View File

@@ -0,0 +1,463 @@
import crypto from "node:crypto";
import { ForbiddenError } from "@casl/ability";
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import { IdentityAuthMethod } from "@app/db/schemas";
import {
OrgPermissionActions,
OrgPermissionSubjects
} from "@app/ee/services/permission/org-permission";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { isAtLeastAsPrivileged } from "@app/lib/casl";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { ActorType, AuthTokenType } from "../auth/auth-type";
import { TIdentityDalFactory } from "../identity/identity-dal";
import { TIdentityOrgDalFactory } from "../identity/identity-org-dal";
import { TIdentityAccessTokenDalFactory } from "../identity-access-token/identity-access-token-dal";
import { TUaClientSecretDalFactory } from "./ua-client-secret-dal";
import { TUniversalAuthDalFactory } from "./universal-auth-dal";
import {
TAttachUaDTO,
TCreateUaClientSecretDTO,
TGetUaClientSecretsDTO,
TGetUaDTO,
TRevokeUaClientSecretDTO,
TUpdateUaDTO
} from "./universal-auth-types";
type TUniversalAuthServiceFactoryDep = {
universalAuthDal: TUniversalAuthDalFactory;
uaClientSecretDal: TUaClientSecretDalFactory;
identityAccessTokenDal: TIdentityAccessTokenDalFactory;
identityOrgDal: TIdentityOrgDalFactory;
identityDal: Pick<TIdentityDalFactory, "updateById">;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
};
export type TUniversalAuthServiceFactory = ReturnType<typeof universalAuthServiceFactory>;
export const universalAuthServiceFactory = ({
universalAuthDal,
uaClientSecretDal,
identityAccessTokenDal,
identityOrgDal,
identityDal,
permissionService
}: TUniversalAuthServiceFactoryDep) => {
const login = async (clientId: string, clientSecret: string) => {
const identityUa = await universalAuthDal.findOne({ clientId });
if (!identityUa) throw new UnauthorizedError();
// TODO(akhilmhdh-pg): add ip checking
const clientSecrtInfo = await uaClientSecretDal.find({
identityUAId: identityUa.id,
isClientSecretRevoked: false
});
const validClientSecretInfo = clientSecrtInfo.find(({ clientSecretHash }) =>
bcrypt.compareSync(clientSecret, clientSecretHash)
);
if (!validClientSecretInfo) throw new UnauthorizedError();
const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } =
validClientSecretInfo;
if (clientSecretTTL > 0) {
const clientSecretCreated = new Date(validClientSecretInfo.createdAt);
const ttlInMilliseconds = clientSecretTTL * 1000;
const currentDate = new Date();
const expirationTime = new Date(clientSecretCreated.getTime() + ttlInMilliseconds);
if (currentDate > expirationTime) {
await uaClientSecretDal.updateById(validClientSecretInfo.id, {
isClientSecretRevoked: true
});
throw new UnauthorizedError({
message: "Failed to authenticate identity credentials due to expired client secret"
});
}
}
if (clientSecretNumUsesLimit > 0 && clientSecretNumUses === clientSecretNumUsesLimit) {
// number of times client secret can be used for
// a login operation reached
await uaClientSecretDal.updateById(validClientSecretInfo.id, { isClientSecretRevoked: true });
throw new UnauthorizedError({
message:
"Failed to authenticate identity credentials due to client secret number of uses limit reached"
});
}
const identityAccessToken = await universalAuthDal.transaction(async (tx) => {
const uaClientSecretDoc = await uaClientSecretDal.incrementUsage(
validClientSecretInfo.id,
tx
);
const newToken = await identityAccessTokenDal.create(
{
identityId: identityUa.identityId,
authType: IdentityAuthMethod.Univeral,
isAccessTokenRevoked: false,
identityUAClientSecretId: uaClientSecretDoc.id,
accessTokenTTL: identityUa.accessTokenTTL,
accessTokenMaxTTL: identityUa.accessTokenMaxTTL,
accessTokenNumUses: 0,
accessTokenNumUsesLimit: identityUa.accessTokenNumUsesLimit
},
tx
);
return newToken;
});
const appCfg = getConfig();
const accessToken = jwt.sign(
{
identityId: identityUa.identityId,
clientSecretId: validClientSecretInfo.id,
identityAccessTokenId: identityAccessToken.id,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
},
appCfg.JWT_AUTH_SECRET,
{
expiresIn:
identityAccessToken.accessTokenMaxTTL === 0
? undefined
: identityAccessToken.accessTokenMaxTTL
}
);
return { accessToken, identityUa };
};
const attachUa = async ({
accessTokenMaxTTL,
identityId,
accessTokenNumUsesLimit,
accessTokenTTL,
accessTokenTrustedIps,
clientSecretTrustedIps,
actorId,
actor
}: TAttachUaDTO) => {
const identityMembershipOrg = await identityOrgDal.findOne({ identityId });
if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" });
if (identityMembershipOrg.identity.authMethod)
throw new BadRequestError({
message: "Failed to add universal auth to already configured identity"
});
if (accessTokenMaxTTL > 0 && accessTokenTTL > accessTokenMaxTTL) {
throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" });
}
const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
identityMembershipOrg.orgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionActions.Create,
OrgPermissionSubjects.Identity
);
const reformattedClientSecretTrustedIps = clientSecretTrustedIps.map(
(clientSecretTrustedIp) => {
// TODO(akhilmhdh-pg): add licence server here
if (/* !plan.ipAllowlisting && */ clientSecretTrustedIp.ipAddress !== "0.0.0.0/0")
throw new BadRequestError({
message:
"Failed to add IP access range to service token due to plan restriction. Upgrade plan to add IP access range."
});
if (!isValidIpOrCidr(clientSecretTrustedIp.ipAddress))
throw new BadRequestError({
message: "The IP is not a valid IPv4, IPv6, or CIDR block"
});
return extractIPDetails(clientSecretTrustedIp.ipAddress);
}
);
const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => {
// TODO(akhilmhdh-pg): add licence server here
if (/* !plan.ipAllowlisting && */ accessTokenTrustedIp.ipAddress !== "0.0.0.0/0")
throw new BadRequestError({
message:
"Failed to add IP access range to service token due to plan restriction. Upgrade plan to add IP access range."
});
if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress))
throw new BadRequestError({
message: "The IP is not a valid IPv4, IPv6, or CIDR block"
});
return extractIPDetails(accessTokenTrustedIp.ipAddress);
});
const identityUa = await universalAuthDal.transaction(async (tx) => {
const doc = await universalAuthDal.create(
{
identityId: identityMembershipOrg.identityId,
clientId: crypto.randomUUID(),
clientSecretTrustedIps: JSON.stringify(reformattedClientSecretTrustedIps),
accessTokenMaxTTL,
accessTokenTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps)
},
tx
);
await identityDal.updateById(
identityMembershipOrg.identityId,
{
authMethod: IdentityAuthMethod.Univeral
},
tx
);
return doc;
});
return identityUa;
};
const updateUa = async ({
accessTokenMaxTTL,
identityId,
accessTokenNumUsesLimit,
accessTokenTTL,
accessTokenTrustedIps,
clientSecretTrustedIps,
actorId,
actor
}: TUpdateUaDTO) => {
const identityMembershipOrg = await identityOrgDal.findOne({ identityId });
if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" });
if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.Univeral)
throw new BadRequestError({
message: "Failed to updated universal auth"
});
const uaIdentityAuth = await universalAuthDal.findOne({ identityId });
if (
(accessTokenMaxTTL || uaIdentityAuth.accessTokenMaxTTL) > 0 &&
(accessTokenTTL || uaIdentityAuth.accessTokenMaxTTL) >
(accessTokenMaxTTL || uaIdentityAuth.accessTokenMaxTTL)
) {
throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" });
}
const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
identityMembershipOrg.orgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionActions.Edit,
OrgPermissionSubjects.Identity
);
const reformattedClientSecretTrustedIps = clientSecretTrustedIps?.map(
(clientSecretTrustedIp) => {
// TODO(akhilmhdh-pg): add licence server here
if (/* !plan.ipAllowlisting && */ clientSecretTrustedIp.ipAddress !== "0.0.0.0/0")
throw new BadRequestError({
message:
"Failed to add IP access range to service token due to plan restriction. Upgrade plan to add IP access range."
});
if (!isValidIpOrCidr(clientSecretTrustedIp.ipAddress))
throw new BadRequestError({
message: "The IP is not a valid IPv4, IPv6, or CIDR block"
});
return extractIPDetails(clientSecretTrustedIp.ipAddress);
}
);
const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => {
// TODO(akhilmhdh-pg): add licence server here
if (/* !plan.ipAllowlisting && */ accessTokenTrustedIp.ipAddress !== "0.0.0.0/0")
throw new BadRequestError({
message:
"Failed to add IP access range to service token due to plan restriction. Upgrade plan to add IP access range."
});
if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress))
throw new BadRequestError({
message: "The IP is not a valid IPv4, IPv6, or CIDR block"
});
return extractIPDetails(accessTokenTrustedIp.ipAddress);
});
const updatedUaAuth = await universalAuthDal.updateById(uaIdentityAuth.id, {
clientSecretTrustedIps: reformattedClientSecretTrustedIps
? JSON.stringify(reformattedClientSecretTrustedIps)
: undefined,
accessTokenMaxTTL,
accessTokenTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps: reformattedAccessTokenTrustedIps
? JSON.stringify(reformattedAccessTokenTrustedIps)
: undefined
});
return updatedUaAuth;
};
const getIdentityUa = async ({ identityId, actorId, actor }: TGetUaDTO) => {
const identityMembershipOrg = await identityOrgDal.findOne({ identityId });
if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" });
if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.Univeral)
throw new BadRequestError({
message: "The identity does not have universal auth"
});
const uaIdentityAuth = await universalAuthDal.findOne({ identityId });
const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
identityMembershipOrg.orgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionActions.Read,
OrgPermissionSubjects.Identity
);
return uaIdentityAuth;
};
const createUaClientSecret = async ({
actor,
actorId,
identityId,
ttl,
description,
numUsesLimit
}: TCreateUaClientSecretDTO) => {
const identityMembershipOrg = await identityOrgDal.findOne({ identityId });
if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" });
if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.Univeral)
throw new BadRequestError({
message: "The identity does not have universal auth"
});
const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
identityMembershipOrg.orgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionActions.Create,
OrgPermissionSubjects.Identity
);
const { permission: rolePermission } = await permissionService.getOrgPermission(
ActorType.IDENTITY,
identityMembershipOrg.identityId,
identityMembershipOrg.orgId
);
const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission);
if (!hasPriviledge)
throw new ForbiddenRequestError({
message: "Failed to add identity to project with more privileged role"
});
const appCfg = getConfig();
const clientSecret = crypto.randomBytes(32).toString("hex");
const clientSecretHash = await bcrypt.hash(clientSecret, appCfg.SALT_ROUNDS);
const identityUniversalAuth = await universalAuthDal.findOne({
identityId
});
const identityUaClientSecret = await uaClientSecretDal.create({
identityUAId: identityUniversalAuth.id,
description,
clientSecretPrefix: clientSecret.slice(0, 4),
clientSecretHash,
clientSecretNumUses: 0,
clientSecretNumUsesLimit: numUsesLimit,
clientSecretTTL: ttl,
isClientSecretRevoked: false
});
return { clientSecret: identityUaClientSecret, uaAuth: identityUniversalAuth };
};
const getUaClientSecrets = async ({ actor, actorId, identityId }: TGetUaClientSecretsDTO) => {
const identityMembershipOrg = await identityOrgDal.findOne({ identityId });
if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" });
if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.Univeral)
throw new BadRequestError({
message: "The identity does not have universal auth"
});
const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
identityMembershipOrg.orgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionActions.Read,
OrgPermissionSubjects.Identity
);
const { permission: rolePermission } = await permissionService.getOrgPermission(
ActorType.IDENTITY,
identityMembershipOrg.identityId,
identityMembershipOrg.orgId
);
const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission);
if (!hasPriviledge)
throw new ForbiddenRequestError({
message: "Failed to add identity to project with more privileged role"
});
const identityUniversalAuth = await universalAuthDal.findOne({
identityId
});
const clientSecrets = await uaClientSecretDal.findOne({
identityUAId: identityUniversalAuth.id,
isClientSecretRevoked: false
});
return clientSecrets;
};
const revokeUaClientSecret = async ({
identityId,
actorId,
actor,
clientSecretId
}: TRevokeUaClientSecretDTO) => {
const identityMembershipOrg = await identityOrgDal.findOne({ identityId });
if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" });
if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.Univeral)
throw new BadRequestError({
message: "The identity does not have universal auth"
});
const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
identityMembershipOrg.orgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionActions.Delete,
OrgPermissionSubjects.Identity
);
const { permission: rolePermission } = await permissionService.getOrgPermission(
ActorType.IDENTITY,
identityMembershipOrg.identityId,
identityMembershipOrg.orgId
);
const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission);
if (!hasPriviledge)
throw new ForbiddenRequestError({
message: "Failed to add identity to project with more privileged role"
});
const clientSecret = await uaClientSecretDal.updateById(clientSecretId, {
isClientSecretRevoked: true
});
return clientSecret;
};
return {
login,
attachUa,
updateUa,
getIdentityUa,
createUaClientSecret,
getUaClientSecrets,
revokeUaClientSecret
};
};

View File

@@ -0,0 +1,39 @@
import { TProjectPermission } from "@app/lib/types";
export type TAttachUaDTO = {
identityId: string;
accessTokenTTL: number;
accessTokenMaxTTL: number;
accessTokenNumUsesLimit: number;
clientSecretTrustedIps: { ipAddress: string }[];
accessTokenTrustedIps: { ipAddress: string }[];
} & Omit<TProjectPermission, "projectId">;
export type TUpdateUaDTO = {
identityId: string;
accessTokenTTL?: number;
accessTokenMaxTTL?: number;
accessTokenNumUsesLimit?: number;
clientSecretTrustedIps?: { ipAddress: string }[];
accessTokenTrustedIps?: { ipAddress: string }[];
} & Omit<TProjectPermission, "projectId">;
export type TGetUaDTO = {
identityId: string;
} & Omit<TProjectPermission, "projectId">;
export type TCreateUaClientSecretDTO = {
identityId: string;
description?: string;
numUsesLimit: number;
ttl: number;
} & Omit<TProjectPermission, "projectId">;
export type TGetUaClientSecretsDTO = {
identityId: string;
} & Omit<TProjectPermission, "projectId">;
export type TRevokeUaClientSecretDTO = {
identityId: string;
clientSecretId: string;
} & Omit<TProjectPermission, "projectId">;