Skip to content

Backend ceadr refactoring#1680

Merged
Artuomka merged 7 commits intomainfrom
backend_ceadr_refactoring
Mar 23, 2026
Merged

Backend ceadr refactoring#1680
Artuomka merged 7 commits intomainfrom
backend_ceadr_refactoring

Conversation

@Artuomka
Copy link
Copy Markdown
Collaborator

@Artuomka Artuomka commented Mar 20, 2026

Summary by CodeRabbit

  • Refactor
    • Permissions are now managed exclusively via Cedar policies instead of separate permission records.
    • Table "readonly" is recalculated from final access flags (visibility, add/edit/delete) rather than set immediately.
    • Permission lookups derive from Cedar policy parsing rather than stored permission entities.
  • Migration
    • Database schema updated to remove legacy permission tables.

Copilot AI review requested due to automatic review settings March 20, 2026 18:01
@Artuomka Artuomka enabled auto-merge March 20, 2026 18:02
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 20, 2026

📝 Walkthrough

Walkthrough

The PR removes the PermissionEntity/table and related repository/interfaces/utilities, migrates permission logic to Cedar policies stored on GroupEntity, replaces permission-table queries with Cedar policy parsing, and adds a migration to drop permission DB tables; multiple tests and use-cases were adjusted accordingly.

Changes

Cohort / File(s) Summary
Core permission model removed
backend/src/entities/permission/permission.entity.ts, backend/src/migrations/1771545700000-RemovePermissionTables.ts
Deleted PermissionEntity and added a migration that drops the permission tables (and recreates them in down()).
Repository & interface removals
backend/src/entities/permission/repository/permission-custom-repository-extension.ts, backend/src/entities/permission/repository/permission.repository.interface.ts
Removed custom permission repository extension and the IPermissionRepository interface (all permission CRUD and query methods deleted).
User-access and permission repo consumers
backend/src/entities/user-access/repository/user-access-custom-repository-extension.ts, backend/src/common/application/global-database-context.ts, backend/src/common/application/global-database-context.interface.ts
Removed user-access custom repo; removed permissionRepository wiring and its interface entry from global DB context.
Use-cases updated to use Cedar
backend/src/entities/connection/use-cases/..., backend/src/entities/permission/use-cases/create-or-update-permissions.use.case.ts, backend/src/entities/permission/use-cases/get-permissions-for-group-in-connection.use.case.ts
Eliminated creation/query of PermissionEntity rows. Use-cases now generate/save Cedar policy on groups and parse group.cedarPolicy to derive classical permissions.
Cedar auth service & parser
backend/src/entities/cedar-authorization/cedar-authorization.service.ts, backend/src/entities/cedar-authorization/cedar-policy-parser.ts
Stopped syncing/deleting classical PermissionEntity rows when saving policies; parser now computes table readonly as a post-processing step from combined flags.
Utilities & factories removed/changed
backend/src/entities/permission/utils/*, backend/src/entities/user/utils/build-default-admin-permissions.ts, backend/src/entities/user/utils/build-default-admin-groups.ts, backend/test/mock.factory.ts
Removed utilities that built PermissionEntity instances and adjusted mock factory and default-admin generators to stop initializing permissions arrays.
Modules & TypeORM entity registrations updated
backend/src/entities/*/*.module.ts (company-info, connection, custom-field, group, permission, table, table-settings, user, etc.)
Removed PermissionEntity imports and dropped it from TypeOrmModule.forFeature registrations; adjusted PermissionModule routes/middleware declarations.
Group entity & repository changes
backend/src/entities/group/group.entity.ts, backend/src/entities/group/repository/group-custom-repository-extension.ts, backend/src/entities/group/repository/group.repository.interface.ts
Removed GroupEntity.many-to-many permissions relation; replaced findGroupWithPermissionsById with findGroupById and updated admin-group detection queries to rely on cedarPolicy/isMain.
Cedar migration and main startup
backend/src/entities/cedar-authorization/scripts/migrate-permissions-to-cedar.ts, backend/src/main.ts
Removed the migration script that converted DB permissions into Cedar policies and removed its invocation from startup.
Tests updated/removed
backend/test/ava-tests/* (policy-parser/test save-policy e2e updates; several migration e2e files removed)
Adjusted assertions expecting readonly to be false when write flags present; large migration e2e suites removed.
Demo & demo-data changes
backend/src/entities/demo-data/demo-data.service.ts
Removed default admin PermissionEntity creation during demo setup and related imports/operations.

Sequence Diagram

sequenceDiagram
    participant Admin as Admin
    participant UC as UseCase
    participant Generator as CedarGenerator
    participant DB as GroupRepository
    participant Parser as CedarParser

    Admin->>UC: Request create/update permissions
    UC->>Generator: Generate cedarPolicy from classical permissions
    Generator-->>UC: cedarPolicy string
    UC->>DB: Save group.cedarPolicy
    DB-->>UC: Persisted Group

    Admin->>UC: Request get permissions for group
    UC->>DB: Fetch Group (includes cedarPolicy)
    DB-->>UC: Group + cedarPolicy
    UC->>Parser: parse cedarPolicy -> classical permissions
    Parser-->>UC: Connection/Group/Table access set
    UC-->>Admin: Return permissions derived from cedarPolicy
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

🐰 I hopped through tables, rows set free,
Policies planted on every tree.
No more join-tangles, no cascade fright,
One cedar breeze sets access right. 🌿✨

🚥 Pre-merge checks | ✅ 2 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Backend ceadr refactoring' is vague and contains what appears to be a typo ('ceadr' instead of 'cedar'). It does not clearly convey the main purpose of this large refactoring, which is removing the legacy permission system entirely in favor of Cedar policy-based authorization. Clarify the title to explicitly describe the primary change, such as 'Remove legacy permission system and migrate to Cedar policy-based authorization' or 'Refactor authorization to use Cedar policies instead of permission entities'.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Security Check ✅ Passed The pull request successfully migrates to Cedar policy-based authorization while maintaining security and proper authorization checks throughout.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch backend_ceadr_refactoring

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refactors backend authorization to rely more heavily on Cedar policies (and policy parsing) rather than legacy PermissionEntity rows, and updates the “readonly” table permission semantics to be derived rather than explicitly set.

Changes:

  • Update Cedar policy parsing so readonly is computed from other table actions (and adjust related tests).
  • Remove several legacy permission-building helpers and a legacy user-access repository extension.
  • Update connection/demo/test-connection flows to set group.cedarPolicy directly, and simplify the “create/update permissions” use case to only persist Cedar policy.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
backend/test/ava-tests/saas-tests/saas-cedar-save-policy-e2e.test.ts Updates expected readonly semantics in SaaS E2E test.
backend/test/ava-tests/saas-tests/saas-cedar-migration-existing-users-e2e.test.ts Adjusts migration test setup to simulate pre-Cedar permissions without removed helper.
backend/test/ava-tests/non-saas-tests/non-saas-cedar-save-policy-e2e.test.ts Updates expected readonly semantics in non-SaaS E2E test.
backend/test/ava-tests/non-saas-tests/non-saas-cedar-policy-parser.test.ts Updates parser unit test expectation for derived readonly.
backend/src/microservices/saas-microservice/use-cases/create-connection-for-hosted-db.use.case.ts Stops creating default admin PermissionEntity rows; sets Cedar policy on admin group.
backend/src/entities/user/utils/build-default-admin-permissions.ts Deletes legacy helper for default admin permission entities.
backend/src/entities/user-access/repository/user-access-custom-repository-extension.ts Deletes legacy user-access repository extension based on PermissionEntity rows.
backend/src/entities/permission/utils/build-new-permission-entity-group.ts Deletes legacy permission-entity builder utility.
backend/src/entities/permission/utils/build-new-permission-entity-connection.ts Deletes legacy permission-entity builder utility.
backend/src/entities/permission/utils/build-final-tables-permissions.ts Deletes legacy table-permissions aggregation helper.
backend/src/entities/permission/use-cases/create-or-update-permissions.use.case.ts Simplifies to generating/saving Cedar policy and returning request-shaped permissions.
backend/src/entities/permission/repository/permission.repository.interface.ts Removes unused legacy permission-repo methods from the interface.
backend/src/entities/permission/repository/permission-custom-repository-extension.ts Removes unused legacy repository methods (table-specific fetch, user table perms).
backend/src/entities/demo-data/demo-data.service.ts Removes creation of default admin permission entities; sets Cedar policy for demo groups.
backend/src/entities/connection/use-cases/get-permissions-for-group-in-connection.use.case.ts Computes group permissions by parsing Cedar policy instead of querying legacy permissions.
backend/src/entities/connection/use-cases/find-all-connections.use.case.ts Creates Cedar policies for test connections instead of legacy default permissions.
backend/src/entities/connection/use-cases/create-connection.use.case.ts Stops creating default admin PermissionEntity rows; sets Cedar policy on admin group.
backend/src/entities/cedar-authorization/cedar-policy-parser.ts Derives readonly after parsing table actions; stops setting it directly on table:read.
backend/src/entities/cedar-authorization/cedar-authorization.service.ts Stops syncing Cedar policies back into legacy PermissionEntity rows.
Comments suppressed due to low confidence (1)

backend/src/entities/permission/use-cases/create-or-update-permissions.use.case.ts:77

  • This use case no longer creates/updates PermissionEntity records at all. There are still runtime code paths that rely on classical permissions stored in group.permissions (e.g., groupRepository.findAllUsersInGroupsWhereUserIsAdmin is used by table log queries to decide whether a non-connection-admin can see other users’ logs). With this change, those queries will stop recognizing group admins unless they’re also connection admins. Either migrate those remaining call sites to Cedar evaluation or keep classical permissions in sync until the migration is complete.
		// Generate and save Cedar policy for this group
		const cedarPolicy = generateCedarPolicyForGroup(connectionId, groupToUpdate.isMain, resultPermissions);
		groupToUpdate.cedarPolicy = cedarPolicy;
		await this._dbContext.groupRepository.saveNewOrUpdatedGroup(groupToUpdate);
		Cacher.invalidateCedarPolicyCache(connectionId);

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


result.tables = Array.from(tableMap.values());
for (const table of result.tables) {
const a = table.accessLevel;
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseCedarPolicyToClassicalPermissions currently sets visibility only when it sees table:read. If a Cedar policy permits table:add/table:edit/table:delete without an explicit table:read (which is syntactically valid and passes validatePolicyReferences), the parser will return add/edit/delete: true but visibility: false, producing an inconsistent classical permission shape. To keep parity with CedarPermissionsService (where visibility = canRead || canAdd || canEdit || canDelete), consider deriving visibility from any of the table actions and then computing readonly from the derived booleans.

Suggested change
const a = table.accessLevel;
const a = table.accessLevel;
// Ensure visibility matches any allowed table action, in parity with CedarPermissionsService
if (!a.visibility && (a.add || a.edit || a.delete)) {
a.visibility = true;
}

Copilot uses AI. Check for mistakes.
Comment on lines +26 to +37
const group = await this._dbContext.groupRepository.findGroupWithPermissionsById(inputData.groupId);

let connectionAccessLevel = AccessLevelEnum.none;
let groupAccessLevel = AccessLevelEnum.none;
const tablePermissionsMap = new Map<string, TablePermissionDs>();

if (group?.cedarPolicy) {
const parsed = parseCedarPolicyToClassicalPermissions(
group.cedarPolicy,
inputData.connectionId,
inputData.groupId,
);
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetPermissionsForGroupInConnectionUseCase now loads the group by groupId only and parses its Cedar policy using the request connectionId, but it never verifies that the group actually belongs to inputData.connectionId. This can misreport permissions (connection/group access is derived without checking the policy resource id) and can disclose another connection’s group policy while listing tables from the requested connection. Suggest fetching the group scoped to the connection (e.g., via findGroupInConnection(groupId, connectionId) or by joining group.connection and validating IDs) before parsing, and failing fast if the group isn’t in the connection.

Copilot uses AI. Check for mistakes.
groupAccessLevel = parsed.group.accessLevel;
for (const table of parsed.tables) {
tablePermissionsMap.set(table.tableName, table);
}
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This use case now returns AccessLevelEnum.none for all permissions when group.cedarPolicy is null/empty, with no fallback to legacy PermissionEntity rows. Since GroupEntity.cedarPolicy is nullable by default, this breaks permission visibility for unmigrated groups. If the system still has pre-Cedar groups in production, consider falling back to the legacy permission repository (or triggering migration) when cedarPolicy is missing.

Suggested change
}
}
} else if (group && Array.isArray((group as any).permissions)) {
const legacyPermissions = (group as any).permissions as Array<any>;
let hasAnyPermission = false;
for (const perm of legacyPermissions) {
const tableName = perm.tableName ?? perm.table_name;
if (!tableName) {
continue;
}
const access = {
add: Boolean(perm.add),
delete: Boolean(perm.delete),
edit: Boolean(perm.edit),
readonly: Boolean(perm.readonly),
visibility: Boolean(perm.visibility),
};
if (
access.add ||
access.delete ||
access.edit ||
access.readonly ||
access.visibility
) {
hasAnyPermission = true;
}
tablePermissionsMap.set(tableName, {
tableName,
accessLevel: access,
});
}
if (hasAnyPermission) {
// Fall back to a non-none access level when legacy permissions exist.
connectionAccessLevel = AccessLevelEnum.readonly;
groupAccessLevel = AccessLevelEnum.readonly;
}

Copilot uses AI. Check for mistakes.
Comment on lines +61 to +71
const resultPermissions: PermissionsDs = {
connection: {
accessLevel: inputData.permissions.connection.accessLevel,
tableName: '',
groupId: groupId,
};
const updated = Object.assign(currentConnectionPermission, updatedPermissionData);
await this._dbContext.permissionRepository.saveNewOrUpdatedPermission(updated);
await this._dbContext.groupRepository.saveNewOrUpdatedGroup(groupToUpdate);
resultPermissions.connection.accessLevel = updated.accessLevel;
} else {
const newPermission = buildNewPermissionEntityConnection(inputData.permissions.connection.accessLevel);
const savedPermission = await this._dbContext.permissionRepository.saveNewOrUpdatedPermission(newPermission);
groupToUpdate.permissions.push(savedPermission);
await this._dbContext.groupRepository.saveNewOrUpdatedGroup(groupToUpdate);
resultPermissions.connection.accessLevel = newPermission.accessLevel as AccessLevelEnum;
}

// *** GROUP PERMISSION
if (currentGroupPermission) {
const updatedPermissionData = {
type: PermissionTypeEnum.Group,
connectionId: inputData.permissions.connection.connectionId,
},
group: {
accessLevel: inputData.permissions.group.accessLevel,
tableName: '',
groupId: groupId,
};
const updated = Object.assign(currentGroupPermission, updatedPermissionData);
await this._dbContext.permissionRepository.saveNewOrUpdatedPermission(updated);
await this._dbContext.groupRepository.saveNewOrUpdatedGroup(groupToUpdate);
resultPermissions.group.accessLevel = updated.accessLevel;
} else {
const newPermission = buildNewPermissionEntityGroup(inputData.permissions.group.accessLevel);
const savedPermission = await this._dbContext.permissionRepository.saveNewOrUpdatedPermission(newPermission);
groupToUpdate.permissions.push(savedPermission);
await this._dbContext.groupRepository.saveNewOrUpdatedGroup(groupToUpdate);
resultPermissions.group.accessLevel = newPermission.accessLevel as AccessLevelEnum;
}

// *** TABLES PERMISSIONS
const tablePermissions = inputData.permissions.tables;
// delete falsy permissions
const deletedPermissions: Array<PermissionEntity> = [];
await Promise.all(
tablePermissions.map(async (tablePermission: TablePermissionDs) => {
const { accessLevel, tableName } = tablePermission;
for (const key in accessLevel) {
// has own property check added to avoid object injection
// eslint-disable-next-line security/detect-object-injection
if (isObjectPropertyExists(accessLevel, key) && !accessLevel[key]) {
const permissionIndex = groupToUpdate.permissions.findIndex(
(permission: PermissionEntity) => permission.accessLevel === key && tableName === permission.tableName,
);
if (permissionIndex >= 0) {
const permissionInGroup = groupToUpdate.permissions.at(permissionIndex);
const deletedPermission =
await this._dbContext.permissionRepository.removePermissionEntity(permissionInGroup);
deletedPermissions.push(deletedPermission);
}
}
}
}),
);
await this._dbContext.groupRepository.saveNewOrUpdatedGroup(groupToUpdate);

//create truthy permissions
const createdPermissions: Array<PermissionEntity> = [];
await Promise.all(
tablePermissions.map(async (tablePermission: TablePermissionDs) => {
const { accessLevel, tableName } = tablePermission;
for (const key in accessLevel) {
// has own property check added to avoid object injection
// eslint-disable-next-line security/detect-object-injection
if (isObjectPropertyExists(accessLevel, key) && accessLevel[key]) {
const permissionIndex = groupToUpdate.permissions.findIndex(
(permission: PermissionEntity) => permission.accessLevel === key && tableName === permission.tableName,
);
if (permissionIndex < 0) {
const permissionEntity = new PermissionEntity();
permissionEntity.type = PermissionTypeEnum.Table;
permissionEntity.accessLevel = key;
permissionEntity.tableName = tableName;
groupToUpdate.permissions.push(permissionEntity);
const createdPermission =
await this._dbContext.permissionRepository.saveNewOrUpdatedPermission(permissionEntity);
createdPermissions.push(createdPermission);
}
}
}
}),
);
await this._dbContext.groupRepository.saveNewOrUpdatedGroup(groupToUpdate);
resultPermissions.tables = buildFinalTablesPermissions(
allTablePermissionsInGroup,
deletedPermissions,
createdPermissions,
);
groupId: inputData.permissions.group.groupId,
},
tables: inputData.permissions.tables,
};
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CreateOrUpdatePermissionsUseCase now just echoes inputData.permissions.tables back to the client and uses it to generate the Cedar policy. Because readonly is now treated as a derived flag (see parseCedarPolicyToClassicalPermissions / CedarPermissionsService), returning the request payload can produce inconsistent readonly values vs what the stored Cedar policy actually grants. Suggest normalizing table access levels before generating (derive visibility / readonly from actions) and/or parsing the generated policy back and returning the parsed permissions as the source of truth.

Copilot uses AI. Check for mistakes.
Comment on lines 105 to 109
const classicalPermissions = parseCedarPolicyToClassicalPermissions(cedarPolicy, connectionId, groupId);

await this.syncClassicalPermissions(group, classicalPermissions);

group.cedarPolicy = cedarPolicy;
await this.globalDbContext.groupRepository.saveNewOrUpdatedGroup(group);
Cacher.invalidateCedarPolicyCache(connectionId);
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

saveCedarPolicy no longer calls syncClassicalPermissions, which means any features still querying group.permissions / PermissionEntity will become stale or stop working after a Cedar policy update. For example, groupRepository.findAllUsersInGroupsWhereUserIsAdmin (used in table log filtering) currently checks group.permissions for PermissionTypeEnum.Group + AccessLevelEnum.edit. If the intent is to fully deprecate classical permissions, those remaining queries should be migrated to Cedar evaluation before removing synchronization.

Copilot uses AI. Check for mistakes.
Comment on lines +83 to +84
const a = table.accessLevel;
a.readonly = a.visibility && !a.add && !a.edit && !a.delete;
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new post-processing loop uses a non-descriptive variable name a for table.accessLevel, which makes the logic harder to read/maintain. Consider renaming it to something explicit like access/accessLevel to match surrounding conventions.

Suggested change
const a = table.accessLevel;
a.readonly = a.visibility && !a.add && !a.edit && !a.delete;
const accessLevel = table.accessLevel;
accessLevel.readonly =
accessLevel.visibility && !accessLevel.add && !accessLevel.edit && !accessLevel.delete;

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/src/entities/permission/use-cases/create-or-update-permissions.use.case.ts (1)

61-79: ⚠️ Potential issue | 🔴 Critical

Keep writing the legacy PermissionEntity rows while the non-Cedar path still exists.

This implementation now only persists group.cedarPolicy and echoes inputData.permissions. The existing migration E2Es still call PUT /permissions/... before Cedar is enabled and expect those changes to take effect immediately, so removing the classical writes here breaks that mode. Gate this Cedar-only branch behind CEDAR_AUTHORIZATION_ENABLED or keep dual-writing until the legacy flow is retired.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@backend/src/entities/permission/use-cases/create-or-update-permissions.use.case.ts`
around lines 61 - 79, The code currently only writes the Cedar policy
(generateCedarPolicyForGroup -> groupToUpdate.cedarPolicy ->
this._dbContext.groupRepository.saveNewOrUpdatedGroup) and returns
resultPermissions, which drops legacy PermissionEntity writes; either gate this
Cedar-only flow behind CEDAR_AUTHORIZATION_ENABLED or perform dual-writing until
legacy is removed: wrap the Cedar generation+save in a feature-flag check
(process.env.CEDAR_AUTHORIZATION_ENABLED) and when the flag is false (or absent)
also persist the legacy permission rows (use the existing Permission
repository/save method used elsewhere in this module — e.g., call the permission
save routine that writes PermissionEntity rows) so E2E migrations that call PUT
/permissions/... continue to take effect immediately.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@backend/src/entities/connection/use-cases/get-permissions-for-group-in-connection.use.case.ts`:
- Around line 26-43: The code currently loads group by groupId and parses its
cedarPolicy against the supplied inputData.connectionId, which can misapply
policies from a different connection; before calling
parseCedarPolicyToClassicalPermissions or trusting group.cedarPolicy, validate
the pair by checking the group's connection association (e.g., ensure
group.connectionId === inputData.connectionId or call the scoped repo method
used by CreateOrUpdatePermissionsUseCase) and throw or return the existing
GROUP_NOT_FROM_THIS_CONNECTION guard if they don't match; then only proceed to
call parseCedarPolicyToClassicalPermissions and populate connectionAccessLevel,
groupAccessLevel, and tablePermissionsMap.

---

Outside diff comments:
In
`@backend/src/entities/permission/use-cases/create-or-update-permissions.use.case.ts`:
- Around line 61-79: The code currently only writes the Cedar policy
(generateCedarPolicyForGroup -> groupToUpdate.cedarPolicy ->
this._dbContext.groupRepository.saveNewOrUpdatedGroup) and returns
resultPermissions, which drops legacy PermissionEntity writes; either gate this
Cedar-only flow behind CEDAR_AUTHORIZATION_ENABLED or perform dual-writing until
legacy is removed: wrap the Cedar generation+save in a feature-flag check
(process.env.CEDAR_AUTHORIZATION_ENABLED) and when the flag is false (or absent)
also persist the legacy permission rows (use the existing Permission
repository/save method used elsewhere in this module — e.g., call the permission
save routine that writes PermissionEntity rows) so E2E migrations that call PUT
/permissions/... continue to take effect immediately.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 70854f65-322b-485a-857e-acd9d03898f3

📥 Commits

Reviewing files that changed from the base of the PR and between da0b62c and e03bf6d.

📒 Files selected for processing (19)
  • backend/src/entities/cedar-authorization/cedar-authorization.service.ts
  • backend/src/entities/cedar-authorization/cedar-policy-parser.ts
  • backend/src/entities/connection/use-cases/create-connection.use.case.ts
  • backend/src/entities/connection/use-cases/find-all-connections.use.case.ts
  • backend/src/entities/connection/use-cases/get-permissions-for-group-in-connection.use.case.ts
  • backend/src/entities/demo-data/demo-data.service.ts
  • backend/src/entities/permission/repository/permission-custom-repository-extension.ts
  • backend/src/entities/permission/repository/permission.repository.interface.ts
  • backend/src/entities/permission/use-cases/create-or-update-permissions.use.case.ts
  • backend/src/entities/permission/utils/build-final-tables-permissions.ts
  • backend/src/entities/permission/utils/build-new-permission-entity-connection.ts
  • backend/src/entities/permission/utils/build-new-permission-entity-group.ts
  • backend/src/entities/user-access/repository/user-access-custom-repository-extension.ts
  • backend/src/entities/user/utils/build-default-admin-permissions.ts
  • backend/src/microservices/saas-microservice/use-cases/create-connection-for-hosted-db.use.case.ts
  • backend/test/ava-tests/non-saas-tests/non-saas-cedar-policy-parser.test.ts
  • backend/test/ava-tests/non-saas-tests/non-saas-cedar-save-policy-e2e.test.ts
  • backend/test/ava-tests/saas-tests/saas-cedar-migration-existing-users-e2e.test.ts
  • backend/test/ava-tests/saas-tests/saas-cedar-save-policy-e2e.test.ts
💤 Files with no reviewable changes (11)
  • backend/src/entities/permission/utils/build-new-permission-entity-group.ts
  • backend/src/entities/connection/use-cases/create-connection.use.case.ts
  • backend/src/entities/permission/utils/build-new-permission-entity-connection.ts
  • backend/src/microservices/saas-microservice/use-cases/create-connection-for-hosted-db.use.case.ts
  • backend/src/entities/demo-data/demo-data.service.ts
  • backend/src/entities/user/utils/build-default-admin-permissions.ts
  • backend/src/entities/permission/repository/permission.repository.interface.ts
  • backend/src/entities/permission/repository/permission-custom-repository-extension.ts
  • backend/src/entities/permission/utils/build-final-tables-permissions.ts
  • backend/src/entities/user-access/repository/user-access-custom-repository-extension.ts
  • backend/src/entities/cedar-authorization/cedar-authorization.service.ts

Comment on lines +26 to +43
const group = await this._dbContext.groupRepository.findGroupWithPermissionsById(inputData.groupId);

let connectionAccessLevel = AccessLevelEnum.none;
let groupAccessLevel = AccessLevelEnum.none;
const tablePermissionsMap = new Map<string, TablePermissionDs>();

if (group?.cedarPolicy) {
const parsed = parseCedarPolicyToClassicalPermissions(
group.cedarPolicy,
inputData.connectionId,
inputData.groupId,
);
connectionAccessLevel = parsed.connection.accessLevel;
groupAccessLevel = parsed.group.accessLevel;
for (const table of parsed.tables) {
tablePermissionsMap.set(table.tableName, table);
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate the group/connection pair before interpreting the policy.

Line 26 now loads the group only by groupId, then parses its Cedar policy against whatever inputData.connectionId the caller supplied. That regresses the old repository scoping: a mismatched pair can now return non-none connection/group access for the wrong connection because the parser promotes connection:* / group:* actions by name. Reuse the same GROUP_NOT_FROM_THIS_CONNECTION guard as CreateOrUpdatePermissionsUseCase here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@backend/src/entities/connection/use-cases/get-permissions-for-group-in-connection.use.case.ts`
around lines 26 - 43, The code currently loads group by groupId and parses its
cedarPolicy against the supplied inputData.connectionId, which can misapply
policies from a different connection; before calling
parseCedarPolicyToClassicalPermissions or trusting group.cedarPolicy, validate
the pair by checking the group's connection association (e.g., ensure
group.connectionId === inputData.connectionId or call the scoped repo method
used by CreateOrUpdatePermissionsUseCase) and throw or return the existing
GROUP_NOT_FROM_THIS_CONNECTION guard if they don't match; then only proceed to
call parseCedarPolicyToClassicalPermissions and populate connectionAccessLevel,
groupAccessLevel, and tablePermissionsMap.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
backend/src/entities/connection/use-cases/get-permissions-for-group-in-connection.use.case.ts (1)

26-43: ⚠️ Potential issue | 🟠 Major

Missing group/connection ownership validation.

The code loads the group by groupId alone and parses its Cedar policy against inputData.connectionId without verifying the group actually belongs to that connection. This mirrors the validation gap flagged in a previous review.

A request with a valid groupId from connection A paired with connectionId from connection B would parse policies incorrectly, potentially exposing permission data or causing authorization logic errors.

Apply the same guard used in CedarAuthorizationService.saveCedarPolicy:

🛡️ Proposed fix
-		const group = await this._dbContext.groupRepository.findGroupById(inputData.groupId);
+		const group = await this._dbContext.groupRepository.findGroupByIdWithConnectionAndUsers(inputData.groupId);
+
+		if (group && group.connection?.id !== inputData.connectionId) {
+			// Group does not belong to this connection - treat as no permissions
+			return {
+				connection: { connectionId: inputData.connectionId, accessLevel: AccessLevelEnum.none },
+				group: { groupId: inputData.groupId, accessLevel: AccessLevelEnum.none },
+				tables: [],
+			};
+		}

 		let connectionAccessLevel = AccessLevelEnum.none;

Alternatively, throw an HttpException with Messages.GROUP_NOT_FROM_THIS_CONNECTION to match the pattern in CreateOrUpdatePermissionsUseCase.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@backend/src/entities/connection/use-cases/get-permissions-for-group-in-connection.use.case.ts`
around lines 26 - 43, The code calls
this._dbContext.groupRepository.findGroupById(inputData.groupId) and then parses
the group's cedarPolicy with parseCedarPolicyToClassicalPermissions using
inputData.connectionId without verifying the group belongs to that connection;
add the same ownership guard used in
CedarAuthorizationService.saveCedarPolicy/CreateOrUpdatePermissionsUseCase by
checking that the loaded group's connectionId equals inputData.connectionId and
if not throw an HttpException with Messages.GROUP_NOT_FROM_THIS_CONNECTION (or
return early as per project pattern) before calling
parseCedarPolicyToClassicalPermissions and populating tablePermissionsMap.
🧹 Nitpick comments (4)
backend/src/entities/group/repository/group-custom-repository-extension.ts (1)

80-93: Cedar policy detection via LIKE patterns is fragile.

The LIKE patterns '%principal, action, resource%' and '%group:edit%' may produce false positives (matching comments, string literals, or partial action names like group:edit_members) or false negatives (if policy formatting varies). Consider reusing the Cedar policy parser (parseCedarPolicyToClassicalPermissions) post-fetch for accurate detection, or document this as an intentional performance trade-off.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/entities/group/repository/group-custom-repository-extension.ts`
around lines 80 - 93, The current admin-group lookup uses fragile SQL LIKE
checks on group.cedarPolicy in the createQueryBuilder that populates
adminGroupIds; instead, remove the cedarPolicy LIKE predicates from the query,
fetch candidate groups (or fetch all groups the user is in for the connection)
using createQueryBuilder('group')...where('user.id = :userId', { userId
}).andWhere('connection.id = :connectionId', { connectionId }).getMany() and
then apply the parseCedarPolicyToClassicalPermissions function in-memory to each
group's cedarPolicy to determine true admin rights, keeping only groups that the
parser says grant the relevant permissions (or alternatively add a code comment
documenting the intentional performance tradeoff if you choose to keep the LIKE
filters).
backend/src/entities/cedar-authorization/cedar-authorization.service.ts (2)

247-249: Same untyped exception access pattern.

Apply consistent exception handling here as well.

🔧 Suggested fix
 		} catch (e) {
-			throw new HttpException({ message: `Invalid cedar policy: ${e.message}` }, HttpStatus.BAD_REQUEST);
+			const message = e instanceof Error ? e.message : String(e);
+			throw new HttpException({ message: `Invalid cedar policy: ${message}` }, HttpStatus.BAD_REQUEST);
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/entities/cedar-authorization/cedar-authorization.service.ts`
around lines 247 - 249, The catch block in cedar-authorization.service.ts
currently accesses e.message unsafely when rethrowing an HttpException (the
throw new HttpException({ message: `Invalid cedar policy: ${e.message}` }, ... )
line); change the catch to catch (e: unknown) and derive a safe message (e.g.,
const msg = e instanceof Error ? e.message : String(e)) before passing it into
the HttpException so you never assume the thrown value has a message property.

158-161: Untyped exception access.

The catch block accesses e.message without type narrowing. In TypeScript strict mode or edge cases, e could be a non-Error type.

🔧 Suggested fix
 		} catch (e) {
 			if (e instanceof HttpException) throw e;
-			throw new HttpException({ message: `Invalid cedar schema: ${e.message}` }, HttpStatus.BAD_REQUEST);
+			const message = e instanceof Error ? e.message : String(e);
+			throw new HttpException({ message: `Invalid cedar schema: ${message}` }, HttpStatus.BAD_REQUEST);
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/entities/cedar-authorization/cedar-authorization.service.ts`
around lines 158 - 161, The catch block in CedarAuthorizationService currently
reads `catch (e) { if (e instanceof HttpException) throw e; throw new
HttpException({ message: \`Invalid cedar schema: ${e.message}\` },
HttpStatus.BAD_REQUEST); }` and accesses e.message without type narrowing;
update the handler to safely derive a message (e.g., const msg = e instanceof
Error ? e.message : String(e)) and use that msg in the HttpException payload so
non-Error throws are handled; apply this change in the catch inside the
CedarAuthorizationService method where the schema validation occurs.
backend/src/migrations/1771545700000-RemovePermissionTables.ts (1)

15-23: Consider adding an index on groupId for the join table rollback.

While the composite primary key covers lookups by permissionId, queries filtering by groupId (e.g., "get all permissions for a group") would benefit from an index. This is a minor optimization for the rollback scenario.

📊 Suggested improvement for rollback completeness
 		await queryRunner.query(
 			`ALTER TABLE "permission_groups_group" ADD CONSTRAINT "FK_permission_groups_groupId" FOREIGN KEY ("groupId") REFERENCES "group"("id") ON DELETE CASCADE ON UPDATE CASCADE`,
 		);
+		await queryRunner.query(
+			`CREATE INDEX "IDX_permission_groups_groupId" ON "permission_groups_group" ("groupId")`,
+		);
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/migrations/1771545700000-RemovePermissionTables.ts` around lines
15 - 23, Add an index on the join table's groupId to improve lookups during
rollback: after creating the permission_groups_group table (and/or after adding
the FK constraints) add a CREATE INDEX for groupId (e.g., name it
IDX_permission_groups_group_groupId) referencing the permission_groups_group
table and the "groupId" column; update the up/down or rollback sections in
RemovePermissionTables migration so the index is created on migrate up and
dropped on rollback (use the existing table name permission_groups_group and
column name groupId to locate where to insert the index creation/drop).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/src/entities/group/repository/group-custom-repository-extension.ts`:
- Around line 99-105: The current query in group-custom-repository-extension
(the chain starting with
this.manager.getRepository(UserEntity).createQueryBuilder('user') and using
leftJoin('user.groups','group').where('group.id IN (:...groupIds)', { groupIds
}).getMany()) can return duplicate UserEntity rows if a user is in multiple
admin groups; modify the query to enforce uniqueness by adding a DISTINCT
qualifier (e.g., call .distinct(true) on the QueryBuilder or select DISTINCT
user id before fetching) so the returned user list has no duplicates.

---

Duplicate comments:
In
`@backend/src/entities/connection/use-cases/get-permissions-for-group-in-connection.use.case.ts`:
- Around line 26-43: The code calls
this._dbContext.groupRepository.findGroupById(inputData.groupId) and then parses
the group's cedarPolicy with parseCedarPolicyToClassicalPermissions using
inputData.connectionId without verifying the group belongs to that connection;
add the same ownership guard used in
CedarAuthorizationService.saveCedarPolicy/CreateOrUpdatePermissionsUseCase by
checking that the loaded group's connectionId equals inputData.connectionId and
if not throw an HttpException with Messages.GROUP_NOT_FROM_THIS_CONNECTION (or
return early as per project pattern) before calling
parseCedarPolicyToClassicalPermissions and populating tablePermissionsMap.

---

Nitpick comments:
In `@backend/src/entities/cedar-authorization/cedar-authorization.service.ts`:
- Around line 247-249: The catch block in cedar-authorization.service.ts
currently accesses e.message unsafely when rethrowing an HttpException (the
throw new HttpException({ message: `Invalid cedar policy: ${e.message}` }, ... )
line); change the catch to catch (e: unknown) and derive a safe message (e.g.,
const msg = e instanceof Error ? e.message : String(e)) before passing it into
the HttpException so you never assume the thrown value has a message property.
- Around line 158-161: The catch block in CedarAuthorizationService currently
reads `catch (e) { if (e instanceof HttpException) throw e; throw new
HttpException({ message: \`Invalid cedar schema: ${e.message}\` },
HttpStatus.BAD_REQUEST); }` and accesses e.message without type narrowing;
update the handler to safely derive a message (e.g., const msg = e instanceof
Error ? e.message : String(e)) and use that msg in the HttpException payload so
non-Error throws are handled; apply this change in the catch inside the
CedarAuthorizationService method where the schema validation occurs.

In `@backend/src/entities/group/repository/group-custom-repository-extension.ts`:
- Around line 80-93: The current admin-group lookup uses fragile SQL LIKE checks
on group.cedarPolicy in the createQueryBuilder that populates adminGroupIds;
instead, remove the cedarPolicy LIKE predicates from the query, fetch candidate
groups (or fetch all groups the user is in for the connection) using
createQueryBuilder('group')...where('user.id = :userId', { userId
}).andWhere('connection.id = :connectionId', { connectionId }).getMany() and
then apply the parseCedarPolicyToClassicalPermissions function in-memory to each
group's cedarPolicy to determine true admin rights, keeping only groups that the
parser says grant the relevant permissions (or alternatively add a code comment
documenting the intentional performance tradeoff if you choose to keep the LIKE
filters).

In `@backend/src/migrations/1771545700000-RemovePermissionTables.ts`:
- Around line 15-23: Add an index on the join table's groupId to improve lookups
during rollback: after creating the permission_groups_group table (and/or after
adding the FK constraints) add a CREATE INDEX for groupId (e.g., name it
IDX_permission_groups_group_groupId) referencing the permission_groups_group
table and the "groupId" column; update the up/down or rollback sections in
RemovePermissionTables migration so the index is created on migrate up and
dropped on rollback (use the existing table name permission_groups_group and
column name groupId to locate where to insert the index creation/drop).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 53347701-6353-497c-ae66-9bf372a9b584

📥 Commits

Reviewing files that changed from the base of the PR and between e03bf6d and 0d43b88.

📒 Files selected for processing (30)
  • backend/src/common/application/global-database-context.interface.ts
  • backend/src/common/application/global-database-context.ts
  • backend/src/entities/cedar-authorization/cedar-authorization.service.ts
  • backend/src/entities/cedar-authorization/cedar-policy-parser.ts
  • backend/src/entities/cedar-authorization/scripts/migrate-permissions-to-cedar.ts
  • backend/src/entities/company-info/company-info.module.ts
  • backend/src/entities/connection/connection.module.ts
  • backend/src/entities/connection/use-cases/get-permissions-for-group-in-connection.use.case.ts
  • backend/src/entities/connection/utils/build-new-group-entity-for-connection-with-user.ts
  • backend/src/entities/custom-field/custom-field.module.ts
  • backend/src/entities/group/group.entity.ts
  • backend/src/entities/group/group.module.ts
  • backend/src/entities/group/repository/group-custom-repository-extension.ts
  • backend/src/entities/group/repository/group.repository.interface.ts
  • backend/src/entities/permission/permission.entity.ts
  • backend/src/entities/permission/permission.module.ts
  • backend/src/entities/permission/repository/permission-custom-repository-extension.ts
  • backend/src/entities/permission/repository/permission.repository.interface.ts
  • backend/src/entities/permission/use-cases/create-or-update-permissions.use.case.ts
  • backend/src/entities/table-settings/common-table-settings/table-settings.module.ts
  • backend/src/entities/table/table.module.ts
  • backend/src/entities/user/user.module.ts
  • backend/src/entities/user/utils/build-default-admin-groups.ts
  • backend/src/enums/index.ts
  • backend/src/enums/permission-type.enum.ts
  • backend/src/main.ts
  • backend/src/migrations/1771545700000-RemovePermissionTables.ts
  • backend/test/ava-tests/non-saas-tests/non-saas-cedar-migration-existing-users-e2e.test.ts
  • backend/test/ava-tests/saas-tests/saas-cedar-migration-existing-users-e2e.test.ts
  • backend/test/mock.factory.ts
💤 Files with no reviewable changes (22)
  • backend/src/entities/user/utils/build-default-admin-groups.ts
  • backend/src/entities/connection/connection.module.ts
  • backend/src/entities/user/user.module.ts
  • backend/src/enums/index.ts
  • backend/src/main.ts
  • backend/src/entities/custom-field/custom-field.module.ts
  • backend/src/entities/connection/utils/build-new-group-entity-for-connection-with-user.ts
  • backend/src/entities/table/table.module.ts
  • backend/src/entities/group/group.module.ts
  • backend/src/common/application/global-database-context.interface.ts
  • backend/src/entities/table-settings/common-table-settings/table-settings.module.ts
  • backend/src/entities/company-info/company-info.module.ts
  • backend/src/enums/permission-type.enum.ts
  • backend/src/entities/permission/permission.module.ts
  • backend/src/common/application/global-database-context.ts
  • backend/src/entities/group/group.entity.ts
  • backend/src/entities/permission/permission.entity.ts
  • backend/src/entities/cedar-authorization/scripts/migrate-permissions-to-cedar.ts
  • backend/test/ava-tests/non-saas-tests/non-saas-cedar-migration-existing-users-e2e.test.ts
  • backend/src/entities/permission/repository/permission.repository.interface.ts
  • backend/test/ava-tests/saas-tests/saas-cedar-migration-existing-users-e2e.test.ts
  • backend/src/entities/permission/repository/permission-custom-repository-extension.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • backend/src/entities/cedar-authorization/cedar-policy-parser.ts
  • backend/src/entities/permission/use-cases/create-or-update-permissions.use.case.ts

Comment on lines +99 to +105
const groupIds = adminGroupIds.map((g) => g.id);
return await this.manager
.getRepository(UserEntity)
.createQueryBuilder('user')
.leftJoinAndSelect('user.groups', 'group')
.leftJoinAndSelect('group.connection', 'connection')
.leftJoinAndSelect('group.permissions', 'permission')
.andWhere('user.id = :userId', { userId: userId })
.andWhere('connection.id = :connectionId', { connectionId: connectionId })
.andWhere('permission.type = :permissionType', {
permissionType: PermissionTypeEnum.Group,
})
.andWhere('permission.accessLevel = :permissionAccessLevel', {
permissionAccessLevel: AccessLevelEnum.edit,
});
return await userQb.getMany();
.leftJoin('user.groups', 'group')
.where('group.id IN (:...groupIds)', { groupIds })
.getMany();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential duplicate users in result set.

If a user belongs to multiple admin groups within the connection, they will appear multiple times in the result. Add DISTINCT to deduplicate.

🔧 Proposed fix
 		const groupIds = adminGroupIds.map((g) => g.id);
 		return await this.manager
 			.getRepository(UserEntity)
 			.createQueryBuilder('user')
+			.distinct(true)
 			.leftJoin('user.groups', 'group')
 			.where('group.id IN (:...groupIds)', { groupIds })
 			.getMany();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const groupIds = adminGroupIds.map((g) => g.id);
return await this.manager
.getRepository(UserEntity)
.createQueryBuilder('user')
.leftJoinAndSelect('user.groups', 'group')
.leftJoinAndSelect('group.connection', 'connection')
.leftJoinAndSelect('group.permissions', 'permission')
.andWhere('user.id = :userId', { userId: userId })
.andWhere('connection.id = :connectionId', { connectionId: connectionId })
.andWhere('permission.type = :permissionType', {
permissionType: PermissionTypeEnum.Group,
})
.andWhere('permission.accessLevel = :permissionAccessLevel', {
permissionAccessLevel: AccessLevelEnum.edit,
});
return await userQb.getMany();
.leftJoin('user.groups', 'group')
.where('group.id IN (:...groupIds)', { groupIds })
.getMany();
const groupIds = adminGroupIds.map((g) => g.id);
return await this.manager
.getRepository(UserEntity)
.createQueryBuilder('user')
.distinct(true)
.leftJoin('user.groups', 'group')
.where('group.id IN (:...groupIds)', { groupIds })
.getMany();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/entities/group/repository/group-custom-repository-extension.ts`
around lines 99 - 105, The current query in group-custom-repository-extension
(the chain starting with
this.manager.getRepository(UserEntity).createQueryBuilder('user') and using
leftJoin('user.groups','group').where('group.id IN (:...groupIds)', { groupIds
}).getMany()) can return duplicate UserEntity rows if a user is in multiple
admin groups; modify the query to enforce uniqueness by adding a DISTINCT
qualifier (e.g., call .distinct(true) on the QueryBuilder or select DISTINCT
user id before fetching) so the returned user list has no duplicates.

@Artuomka Artuomka merged commit 357d7cc into main Mar 23, 2026
18 of 20 checks passed
@Artuomka Artuomka deleted the backend_ceadr_refactoring branch March 23, 2026 14:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants