Skip to content

Commit 56d6bb5

Browse files
authored
feat: schema based permission control (#4955)
1 parent 6820c39 commit 56d6bb5

86 files changed

Lines changed: 3149 additions & 644 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/skills/backend-developer/SKILL.md

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ class CreateEntityUseCase implements UseCaseAbstraction.Interface {
177177
) {}
178178

179179
async execute(input: ICreateEntityInput): UseCaseAbstraction.Return {
180-
// Authorization checks.
180+
// Authorization checks (low-level approach; prefer createPermissions — see Schema-Based Permissions section).
181181
if (!this.identityContext.getPermission("mypackage.entity")) {
182182
return Result.fail(
183183
new NotAuthorizedError({
@@ -1140,6 +1140,114 @@ export default PermissionTransformer.createImplementation({
11401140

11411141
---
11421142

1143+
## Schema-Based Permissions (`createPermissions`)
1144+
1145+
Use `createPermissions` to define a typed permissions object from the same schema used by the admin UI. This replaces manual `identityContext.getPermission()` calls with high-level methods like `canRead`, `canCreate`, `canEdit`, `canDelete`, `canAccess`, `onlyOwnRecords`, `canPublish`, `canUnpublish`, and `canAction`.
1146+
1147+
### Defining Permissions (`permissions/schema.ts`)
1148+
1149+
```typescript
1150+
import { createPermissions } from "@webiny/api-core/features/security/permissions/index.js";
1151+
import type { Permissions } from "@webiny/api-core/features/security/permissions/index.js";
1152+
1153+
const schema = {
1154+
prefix: "fm",
1155+
fullAccess: { name: "fm.*" },
1156+
entities: [
1157+
{
1158+
id: "file",
1159+
permission: "fm.file",
1160+
scopes: ["full", "own"],
1161+
actions: [{ name: "rwd" }]
1162+
},
1163+
{
1164+
id: "settings",
1165+
permission: "fm.settings",
1166+
scopes: ["full"]
1167+
}
1168+
]
1169+
} as const;
1170+
1171+
type FmSchema = typeof schema;
1172+
1173+
export const FmPermissions = createPermissions(schema);
1174+
1175+
export namespace FmPermissions {
1176+
export type Interface = Permissions<FmSchema>;
1177+
}
1178+
```
1179+
1180+
`createPermissions` returns `{ Abstraction, Implementation }`. The `Abstraction` is a DI token; the `Implementation` is the auto-registered class. The namespace re-exports `Permissions<FmSchema>` as `.Interface` for consumers.
1181+
1182+
### Re-exporting from shared abstractions
1183+
1184+
```typescript
1185+
// features/shared/abstractions.ts
1186+
export { FmPermissions } from "~/permissions/schema.js";
1187+
```
1188+
1189+
### Using in a Use Case
1190+
1191+
Inject `FmPermissions.Abstraction` as a dependency. The resolved type is `FmPermissions.Interface`, which has typed entity IDs — only `"file"` and `"settings"` are accepted.
1192+
1193+
```typescript
1194+
import { FmPermissions } from "~/features/shared/abstractions.js";
1195+
import { IdentityContext } from "@webiny/api-core/features/security/IdentityContext/index.js";
1196+
1197+
class ListFilesUseCaseImpl implements UseCaseAbstraction.Interface {
1198+
constructor(
1199+
private permissions: FmPermissions.Interface,
1200+
private identityContext: IdentityContext.Interface,
1201+
private repository: ListFilesRepository.Interface
1202+
) {}
1203+
1204+
async execute(input: ListFilesInput) {
1205+
// Gate: does the identity have read access to files at all?
1206+
if (!(await this.permissions.canRead("file"))) {
1207+
return Result.fail(new FileNotAuthorizedError());
1208+
}
1209+
1210+
const where: ListFilesInput["where"] = { ...(input.where || {}) };
1211+
1212+
// Query filter: is this identity restricted to own records?
1213+
if (await this.permissions.onlyOwnRecords("file")) {
1214+
const identity = this.identityContext.getIdentity();
1215+
where.createdBy = identity.id;
1216+
}
1217+
1218+
return this.repository.execute({ ...input, where });
1219+
}
1220+
}
1221+
1222+
export const ListFilesUseCase = UseCaseAbstraction.createImplementation({
1223+
implementation: ListFilesUseCaseImpl,
1224+
dependencies: [FmPermissions.Abstraction, IdentityContext, ListFilesRepository]
1225+
});
1226+
```
1227+
1228+
### Available Methods
1229+
1230+
| Method | Signature | Purpose |
1231+
|---|---|---|
1232+
| `canAccess` | `(entityId, item?) → boolean` | Can the identity access this entity? If `item` is provided, checks ownership when scope is `own`. |
1233+
| `onlyOwnRecords` | `(entityId) → boolean` | Is the identity restricted to own records? Use for query-level filtering. |
1234+
| `canRead` | `(entityId) → boolean` | Has `r` in `rwd` (or full access). |
1235+
| `canCreate` | `(entityId) → boolean` | Has `w` in `rwd` (or full access). |
1236+
| `canEdit` | `(entityId, item?) → boolean` | Has `w` in `rwd`, respecting ownership. |
1237+
| `canDelete` | `(entityId, item?) → boolean` | Has `d` in `rwd`, respecting ownership. |
1238+
| `canPublish` | `(entityId) → boolean` | Has `p` in `pw`. |
1239+
| `canUnpublish` | `(entityId) → boolean` | Has `u` in `pw`. |
1240+
| `canAction` | `(action, entityId) → boolean` | Custom boolean action (e.g. `"import"`). |
1241+
1242+
All methods return `Promise<boolean>` and short-circuit to `true`/`false` for full-access identities.
1243+
1244+
### `canAccess` vs `onlyOwnRecords`
1245+
1246+
- **`canAccess(entityId, item?)`** — gate check. "Can this identity access this entity/item?" Use for single-item operations (get, update, delete). When `item` is provided and all permissions require `own`, verifies `item.createdBy.id === identity.id`.
1247+
- **`onlyOwnRecords(entityId)`** — query filter flag. "Is this identity restricted to own records?" Use for list operations to add a `createdBy` filter. Returns `false` for full access, schema full access, or no permissions; returns `true` only when every permission for the entity requires `own`.
1248+
1249+
---
1250+
11431251
## Scoping Rules
11441252

11451253
| Layer | Scope | Rationale |
@@ -1196,6 +1304,7 @@ To discover existing system features, read `ai-context/core-features-reference.m
11961304
- [ ] Root Extension registers model, schemas, and features.
11971305
- [ ] GraphQL schema implements `GraphQLSchemaFactory.Interface`.
11981306
- [ ] Domain events have handler abstractions with `Interface` + `Event` namespace.
1307+
- [ ] Permissions use `createPermissions` with a schema matching the admin UI, not raw `getPermission()`.
11991308
- [ ] `index.ts` exports abstractions only -- no features, no event classes, no implementations.
12001309
- [ ] All relative imports use `.js` extension.
12011310
- [ ] One class per file, one import per line.

0 commit comments

Comments
 (0)