mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat(infisical-pg: completed machine identity services
This commit is contained in:
@@ -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",
|
||||
|
||||
49
backend-pg/src/@types/knex.d.ts
vendored
49
backend-pg/src/@types/knex.d.ts
vendored
@@ -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,
|
||||
|
||||
21
backend-pg/src/db/migrations/20231228074856_identity.ts
Normal file
21
backend-pg/src/db/migrations/20231228074856_identity.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
20
backend-pg/src/db/schemas/identities.ts
Normal file
20
backend-pg/src/db/schemas/identities.ts
Normal 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>>;
|
||||
28
backend-pg/src/db/schemas/identity-access-tokens.ts
Normal file
28
backend-pg/src/db/schemas/identity-access-tokens.ts
Normal 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>>;
|
||||
22
backend-pg/src/db/schemas/identity-org-memberships.ts
Normal file
22
backend-pg/src/db/schemas/identity-org-memberships.ts
Normal 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>>;
|
||||
22
backend-pg/src/db/schemas/identity-project-memberships.ts
Normal file
22
backend-pg/src/db/schemas/identity-project-memberships.ts
Normal 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>>;
|
||||
29
backend-pg/src/db/schemas/identity-ua-client-secrets.ts
Normal file
29
backend-pg/src/db/schemas/identity-ua-client-secrets.ts
Normal 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>
|
||||
>;
|
||||
27
backend-pg/src/db/schemas/identity-universal-auths.ts
Normal file
27
backend-pg/src/db/schemas/identity-universal-auths.ts
Normal 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>
|
||||
>;
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
44
backend-pg/src/lib/casl/index.ts
Normal file
44
backend-pg/src/lib/casl/index.ts
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
137
backend-pg/src/lib/ip/index.ts
Normal file
137
backend-pg/src/lib/ip/index.ts
Normal 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"
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export type TRenewAccessTokenDTO = {
|
||||
accessToken: string;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
10
backend-pg/src/services/identity/identity-dal.ts
Normal file
10
backend-pg/src/services/identity/identity-dal.ts
Normal 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;
|
||||
};
|
||||
35
backend-pg/src/services/identity/identity-org-dal.ts
Normal file
35
backend-pg/src/services/identity/identity-org-dal.ts
Normal 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 };
|
||||
};
|
||||
149
backend-pg/src/services/identity/identity-service.ts
Normal file
149
backend-pg/src/services/identity/identity-service.ts
Normal 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
|
||||
};
|
||||
};
|
||||
16
backend-pg/src/services/identity/identity-types.ts
Normal file
16
backend-pg/src/services/identity/identity-types.ts
Normal 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">;
|
||||
@@ -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 };
|
||||
};
|
||||
11
backend-pg/src/services/universal-auth/universal-auth-dal.ts
Normal file
11
backend-pg/src/services/universal-auth/universal-auth-dal.ts
Normal 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;
|
||||
};
|
||||
463
backend-pg/src/services/universal-auth/universal-auth-service.ts
Normal file
463
backend-pg/src/services/universal-auth/universal-auth-service.ts
Normal 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
|
||||
};
|
||||
};
|
||||
@@ -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">;
|
||||
Reference in New Issue
Block a user