Files
calendar/packages/features/flags/features.repository.ts
T
sean-brydonGitHubEunjae Leecubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
9dca13305f feat: pbac core ui (#21471)
* migration plus feature flag

* show navigation route + inital roles migration

* add a check permission use case to take in the feature flags for a team

* bulk update script

* inital frontend work for displaying roles

* move to a more "anemic domain models" approach

* update test to match new DDD strutcture

* fix tests

* update transaction call back types to include trx

* align fe types after transaction to DDD

* move away from usecases to a more domain tailored approach

* get permissions per resource and map them to domain permission string

* update permision logic

* correctly get the logic for *.* permissions on owner

* wip sheet logic for ssr

* role list

* use nuqs for sheet parsing on handle change

* fox

* improve hook usage

* enable PBAC router

* delete modal etc

* i18n and inital rough layout of roles + permisions creating

* add color to migrations

* add colors and new method to tests

* move hooks out of infra into client with provider

* move hooks out of infra into client with provider

* memo features and ensure render once

* remove comment

* seed color

* use role colours

* match i18n

* add custom color picker to edit/create form

* fix advanced mode toggle

* more work on adv permission group

* update migrations

* abstract lots of core form logic to a custom hook

* improve UX for selecting all and toggling all

* improve code quality and use domain mappers in role repositoryu

* call server action to revalidate cache

* call invalidate cache on delete

* fix re-render + improves update logic wip

* fix txn for assinging role to member

* wip on assigning users custom roles

* fix repo

* update logic for checking if users can update roles

* remove member from permission check

* check users permission and assign roles

* move to factory approach

* move default rolesIDs to constant

* add facuted values to table

* display custom role in table

* fix type error

* fix role filter

* check pbac feature flag to see what column to filter on

* push repo mocks and other mocks to fix unit tests

* fix and add test for empty permissions when creating a role

* pass updates to repo so we actually update roles

* fix types

* fix types

* restore lock changes

* fix role service test for new updates section

* fix updated at types

* update mocks to use feature repository mock

* remove roletype from db in model

* prevent multiple queries

* fix typeof in role model

* fix and migrate i18n to one registery

* fix and update i18n to be in registery

* fix type error + fall back in service instead of repo for BL

* more type errors

* update members faceted values to bennys refactor

* fix types

* remove the _resource from type conditionally

* fix managment factory types to expose PBAC enbaled obol

* narrow down types

* wip fix for types

* more fix types

* cast role

* fix tests

* attempt of fixing _resoucre key access type

* attempt of fixing _resoucre key access type

* seperate migraations to batches

* add invalidate time to team features

* restore router to main

* push main lock

* Update packages/features/pbac/domain/types/permission-registry.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update packages/features/pbac/domain/mappers/PermissionMapper.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update packages/prisma/migrations/20250527091330_add_color_to_pbac_role/migration.sql

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update packages/prisma/migrations/20250617070118_update_memberships_one_time/migration.sql

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/AdvancedPermissionGroup.tsx

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* fix mapped type

* restore migration

* skip _resource

* use partical

* fix type errors in tests and hooks

* Simplified the role field in the editSchema to use z.nativeEnum(MembershipRole)

* fix type errors for editsheet

* fix type errors for editsheet

* Apply suggestion from comment 2151515295

* remove footer since we dont have docs yet

* add i18n

* lock all toggle chevron

* use prisma

* tidy up old manage permission

* fix i18n

* remove can manage from role permission check

* auto select read

* address benny feedback

* fix type

* fix type

* update function name due to merge

* fix types

* update tests to match new membership method from merge

* address cubic feedback

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-07-07 10:20:24 +01:00

253 lines
8.2 KiB
TypeScript

import { Prisma } from "@prisma/client";
import { captureException } from "@sentry/nextjs";
import db from "@calcom/prisma";
import type { AppFlags, TeamFeatures } from "./config";
import type { IFeaturesRepository } from "./features.repository.interface";
interface CacheOptions {
ttl: number; // time in ms
}
/**
* Repository class for managing feature flags and feature access control.
* Implements the IFeaturesRepository interface to provide feature flag functionality
* for users, teams, and global application features.
*/
export class FeaturesRepository implements IFeaturesRepository {
private static featuresCache: { data: any[]; expiry: number } | null = null;
private clearCache() {
FeaturesRepository.featuresCache = null;
}
/**
* Gets all features with their enabled status.
* Uses caching to avoid hitting the database on every request.
* @returns Promise<Feature[]> - Array of all features
*/
public async getAllFeatures() {
if (FeaturesRepository.featuresCache && Date.now() < FeaturesRepository.featuresCache.expiry) {
return FeaturesRepository.featuresCache.data;
}
const features = await db.feature.findMany({
orderBy: { slug: "asc" },
cacheStrategy: { swr: 300, ttl: 300 },
});
FeaturesRepository.featuresCache = {
data: features,
expiry: Date.now() + 5 * 60 * 1000, // 5 minutes cache
};
return features;
}
/**
* Gets a map of all feature flags and their enabled status.
* Uses caching to avoid hitting the database on every request.
* @returns Promise<AppFlags> - A map of feature flags to their enabled status
*/
public async getFeatureFlagMap() {
const flags = await this.getAllFeatures();
return flags.reduce((acc, flag) => {
acc[flag.slug as keyof AppFlags] = flag.enabled;
return acc;
}, {} as AppFlags);
}
/**
* Gets all features enabled for a specific team in a map format.
* @param teamId - The ID of the team to get features for
* @returns Promise<{ [slug: string]: boolean } | null>
*/
public async getTeamFeatures(teamId: number) {
const result = await db.teamFeatures.findMany({
where: {
teamId,
},
include: {
feature: {
select: {
slug: true,
enabled: true,
},
},
},
});
if (!result.length) return null;
const features: TeamFeatures = Object.fromEntries(
result.map((teamFeature) => [teamFeature.feature.slug, true])
) as TeamFeatures;
return features;
}
/**
* Checks if a feature is enabled globally in the application.
* @param slug - The feature flag identifier to check
* @returns Promise<boolean> - True if the feature is enabled globally, false otherwise
* @throws Error if the feature flag check fails
*/
async checkIfFeatureIsEnabledGlobally(
slug: keyof AppFlags,
_options: CacheOptions = { ttl: 5 * 60 * 1000 }
): Promise<boolean> {
try {
const features = await this.getAllFeatures();
const flag = features.find((f) => f.slug === slug);
return Boolean(flag && flag.enabled);
} catch (err) {
captureException(err);
throw err;
}
}
/**
* Checks if a specific user has access to a feature.
* Checks both direct user feature assignments and team-based feature access.
* @param userId - The ID of the user to check
* @param slug - The feature identifier to check
* @returns Promise<boolean> - True if the user has access to the feature, false otherwise
* @throws Error if the feature access check fails
*/
async checkIfUserHasFeature(userId: number, slug: string) {
try {
/**
* findUnique was failing in prismock tests, so I'm using findFirst instead
* FIXME refactor when upgrading prismock
* https://github.com/morintd/prismock/issues/592
*/
const userHasFeature = await db.userFeatures.findFirst({
where: {
userId,
featureId: slug,
},
});
if (userHasFeature) return true;
// If the user doesn't have the feature, check if they belong to a team with the feature.
// This also covers organizations, which are teams.
const userBelongsToTeamWithFeature = await this.checkIfUserBelongsToTeamWithFeature(userId, slug);
if (userBelongsToTeamWithFeature) return true;
return false;
} catch (err) {
captureException(err);
throw err;
}
}
/**
* Private helper method to check if a user belongs to any team that has access to a feature.
* @param userId - The ID of the user to check
* @param slug - The feature identifier to check
* @returns Promise<boolean> - True if the user belongs to a team with the feature, false otherwise
* @throws Error if the team feature check fails
* @private
*/
private async checkIfUserBelongsToTeamWithFeature(userId: number, slug: string) {
try {
const query = Prisma.sql`
WITH RECURSIVE TeamHierarchy AS (
-- Start with teams the user belongs to
SELECT DISTINCT t.id, t."parentId",
CASE WHEN EXISTS (
SELECT 1 FROM "TeamFeatures" tf
WHERE tf."teamId" = t.id AND tf."featureId" = ${slug}
) THEN true ELSE false END as has_feature
FROM "Team" t
INNER JOIN "Membership" m ON m."teamId" = t.id
WHERE m."userId" = ${userId} AND m.accepted = true
UNION ALL
-- Recursively get parent teams
SELECT DISTINCT p.id, p."parentId",
CASE WHEN EXISTS (
SELECT 1 FROM "TeamFeatures" tf
WHERE tf."teamId" = p.id AND tf."featureId" = ${slug}
) THEN true ELSE false END as has_feature
FROM "Team" p
INNER JOIN TeamHierarchy c ON p.id = c."parentId"
WHERE NOT c.has_feature -- Stop recursion if we found a team with the feature
)
SELECT 1
FROM TeamHierarchy
WHERE has_feature = true
LIMIT 1;
`;
const result = await db.$queryRaw<unknown[]>(query);
return result.length > 0;
} catch (err) {
captureException(err);
throw err;
}
}
/**
* Checks if a team or any of its ancestors has access to a specific feature.
* Uses a recursive CTE raw SQL query for performance.
* @param teamId - The ID of the team to start the check from
* @param featureId - The feature identifier to check
* @returns Promise<boolean> - True if the team or any ancestor has the feature, false otherwise
* @throws Error if the database query fails
*/
async checkIfTeamHasFeature(teamId: number, featureId: keyof AppFlags): Promise<boolean> {
try {
// Early return if team has feature directly assigned
const teamHasFeature = await db.teamFeatures.findUnique({
where: {
teamId_featureId: {
teamId,
featureId,
},
},
});
if (teamHasFeature) return true;
const query = Prisma.sql`
WITH RECURSIVE TeamHierarchy AS (
-- Start with the initial team
SELECT id, "parentId",
CASE WHEN EXISTS (
SELECT 1 FROM "TeamFeatures" tf
WHERE tf."teamId" = id AND tf."featureId" = ${featureId}
) THEN true ELSE false END as has_feature
FROM "Team"
WHERE id = ${teamId}
UNION ALL
-- Recursively get parent teams
SELECT p.id, p."parentId",
CASE WHEN EXISTS (
SELECT 1 FROM "TeamFeatures" tf
WHERE tf."teamId" = p.id AND tf."featureId" = ${featureId}
) THEN true ELSE false END as has_feature
FROM "Team" p
INNER JOIN TeamHierarchy c ON p.id = c."parentId"
WHERE NOT c.has_feature -- Stop recursion if we found a team with the feature
)
SELECT 1
FROM TeamHierarchy
WHERE has_feature = true
LIMIT 1;
`;
const result = await db.$queryRaw<unknown[]>(query);
return result.length > 0;
} catch (err) {
captureException(err);
console.error(
`Recursive feature check failed for team ${teamId}, feature ${featureId}:`,
err instanceof Error ? err.message : err
);
throw err;
}
}
}