Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions app/controllers/base_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ import type {
PrimaryKeyFieldDescriptor,
RelationValidator,
} from "#utils/model_autogen";
import {
deletePermissionsForEntity,
getMorphMapAlias,
} from "#utils/permissions";
import { paginationValidator } from "#validators/pagination";

export interface Scopes<T extends LucidModel> {
Expand Down Expand Up @@ -1088,6 +1092,12 @@ export default abstract class BaseController<
status: 500,
});

// Clean up any permissions scoped to the deleted instance
const morphAlias = getMorphMapAlias(this.model);
if (morphAlias !== null) {
await deletePermissionsForEntity(morphAlias, id);
}

return {
success: true,
};
Expand Down
12 changes: 11 additions & 1 deletion app/controllers/v1/drafts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import type { LucidModel, ModelAttributes } from "@adonisjs/lucid/types/model";

import { validateColumnDef } from "#app/decorators/typed_model";
import type { ValidatedColumnDef } from "#app/decorators/typed_model";
import { thinModel } from "#app/utils/permissions";
import {
deletePermissionsForEntity,
getMorphMapAlias,
thinModel,
} from "#app/utils/permissions";
import { ForbiddenException } from "#exceptions/http_exceptions";
import GuideArticleDraft from "#models/guide_article_draft";
import StudentOrganizationDraft from "#models/student_organization_draft";
Expand Down Expand Up @@ -395,6 +399,12 @@ export abstract class GenericDraftController<
.delete()
.addErrorContext("Failed to delete GuideArticleDraft after approval");

// Clean up any permissions scoped to the deleted instance
const morphAlias = getMorphMapAlias(this.model);
if (morphAlias !== null) {
await deletePermissionsForEntity(morphAlias, draftId, trx);
}

return { success: true, approvedId: approved.id };
});
}
Expand Down
148 changes: 148 additions & 0 deletions app/utils/permissions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,31 @@
import { getClassPath } from "@holoyan/adonisjs-permissions";

import logger from "@adonisjs/core/services/logger";
import db from "@adonisjs/lucid/services/db";
import type { TransactionClientContract } from "@adonisjs/lucid/types/database";
import type { LucidModel, LucidRow } from "@adonisjs/lucid/types/model";

/**
* Breakdown of deleted rows per ACL table after a cleanup operation.
*/
export interface AclCleanupResult {
permissions: number;
roles: number;
modelRoles: number;
modelPermissions: number;
}

export function aclTotal(r: AclCleanupResult): number {
return r.permissions + r.roles + r.modelRoles + r.modelPermissions;
}

const ZERO_RESULT: AclCleanupResult = {
permissions: 0,
roles: 0,
modelRoles: 0,
modelPermissions: 0,
};

/**
* Creates a fake model instance for the purpose of efficient permission checking
*
Expand Down Expand Up @@ -34,3 +60,125 @@ const thinModelHandlers: ProxyHandler<ThinModel> = {
function thinModelGetId(this: ThinModel): number {
return this.id;
}

/**
* Check whether a model class has a morph map registered (i.e. supports ACL permissions).
*
* @param model the model class to check
* @returns the morph map alias if registered, or null otherwise
*/
export function getMorphMapAlias(model: LucidModel): string | null {
try {
return getClassPath(model);
} catch {
return null;
}
}

/**
* Delete all ACL rows that reference a specific model instance.
*
* Cleans four tables (no foreign keys exist on `entity_id`, so this is required):
* - `permissions` — entity_type/entity_id scope (cascades to `model_permissions` via `permission_id` FK)
* - `access_roles` — entity_type/entity_id scope (cascades to `model_roles` via `role_id` FK)
* - `model_roles` — model_type/model_id (role assignments *to* this entity)
* - `model_permissions` — model_type/model_id (direct permission assignments *to* this entity)
*/
export async function deletePermissionsForEntity(
entityType: string,
entityId: number | string,
transaction?: TransactionClientContract,
): Promise<AclCleanupResult> {
const from = transaction?.from.bind(transaction) ?? db.from.bind(db);

const permissions = Number(
await from("permissions")
.where("entity_type", entityType)
.where("entity_id", entityId)
.delete(),
);

const roles = Number(
await from("access_roles")
.where("entity_type", entityType)
.where("entity_id", entityId)
.delete(),
);

const modelRoles = Number(
await from("model_roles")
.where("model_type", entityType)
.where("model_id", entityId)
.delete(),
);

const modelPermissions = Number(
await from("model_permissions")
.where("model_type", entityType)
.where("model_id", entityId)
.delete(),
);

const result: AclCleanupResult = {
permissions,
roles,
modelRoles,
modelPermissions,
};
const total = aclTotal(result);

if (total > 0) {
logger.info(
`Deleted ${total} orphaned ACL row(s) for ${entityType}#${entityId}: ` +
`${permissions} perm, ${roles} role, ` +
`${modelRoles} model_role, ${modelPermissions} model_perm`,
);
}
return result;
}

/**
* Delete all ACL rows whose `entity_type`/`model_type` matches the given model,
* but whose `entity_id`/`model_id` does not correspond to any existing row
* in the model's table.
*
* Useful for bulk cleanup of ACL data that references deleted objects.
*/
export async function deleteOrphanedPermissionsForModel(
model: LucidModel,
): Promise<AclCleanupResult> {
const entityType = getMorphMapAlias(model);
if (entityType === null) {
return { ...ZERO_RESULT };
}

const existingIdSubquery = db.from(model.table).select(model.primaryKey);

// entity_type/entity_id tables (permissions, access_roles)
const deleteEntityOrphans = async (tableName: string): Promise<number> =>
Number(
await db
.from(tableName)
.where("entity_type", entityType)
.whereNotNull("entity_id")
.whereNotIn("entity_id", existingIdSubquery)
.delete(),
);

// model_type/model_id tables (model_roles, model_permissions)
const deleteModelOrphans = async (tableName: string): Promise<number> =>
Number(
await db
.from(tableName)
.where("model_type", entityType)
.whereNotIn("model_id", existingIdSubquery)
.delete(),
);

const permissions = await deleteEntityOrphans("permissions");
const roles = await deleteEntityOrphans("access_roles");
const modelRoles = await deleteModelOrphans("model_roles");
const modelPermissions = await deleteModelOrphans("model_permissions");

return { permissions, roles, modelRoles, modelPermissions };
}
Loading
Loading