Schema-based permission system for API features. Use this skill when implementing authorization in use cases, defining permission schemas with createPermissionSchema, creating injectable permissions via createPermissionsAbstraction/createPermissionsFeature, checking read/write/delete/publish permissions, handling own-record scoping, or testing permission scenarios. Covers the full pattern from schema definition to use case integration to test matrices.
63
75%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Fix and improve this skill with Tessl
tessl review fix ./skills/user-skills/api/permissions/SKILL.mdPermissions follow two layers: domain (schema) and features (DI abstractions + feature registration). Each package declares a permission schema and gets a typed Permissions abstraction injectable into use cases via DI. Methods like canRead, canEdit, canDelete, canPublish, onlyOwnRecords replace manual identityContext.getPermission() calls.
Define the schema in src/domain/permissionsSchema.ts:
import { createPermissionSchema } from "webiny/api/security";
export const SM_PERMISSIONS_SCHEMA = createPermissionSchema({
prefix: "sm",
fullAccess: true,
entities: [
{
id: "product",
permission: "sm.product",
scopes: ["full", "own"],
actions: [{ name: "rwd" }, { name: "pw" }]
},
{
id: "settings",
permission: "sm.settings",
scopes: ["full"]
}
]
});The schema MUST use as const inference (handled by createPermissionSchema) for TypeScript to narrow entity IDs in method signatures.
| Field | Description |
|---|---|
prefix | Namespaces the DI abstraction: ${prefix}:Permissions |
fullAccess | true for standard full access. Pass an object with custom boolean flags for full-access extras (e.g., { canForceUnlock: true }). |
entities[].id | Entity identifier used in method calls: canRead("product") |
entities[].permission | Permission name matched against identity permissions |
entities[].scopes | ["full"] or ["full", "own"] — determines if own-scope supported |
entities[].actions | Action definitions — built-in: "rwd", "pw"; custom: boolean flags |
"full" — User can access all records (default when no own flag on permission object)"own" — User can only access records where createdBy.id === identity.idOmit entities for binary full/no access:
export const MA_PERMISSIONS_SCHEMA = createPermissionSchema({
prefix: "ma",
fullAccess: true
});src/features/permissions/abstractions.ts)import { createPermissionsAbstraction } from "webiny/api/security";
import type { Permissions } from "webiny/api/security";
import { SM_PERMISSIONS_SCHEMA } from "~/domain/permissionsSchema.js";
export const SmPermissions = createPermissionsAbstraction(SM_PERMISSIONS_SCHEMA);
export namespace SmPermissions {
export type Interface = Permissions<typeof SM_PERMISSIONS_SCHEMA>;
}src/features/permissions/feature.ts)import { createPermissionsFeature } from "webiny/api/security";
import { SM_PERMISSIONS_SCHEMA } from "~/domain/permissionsSchema.js";
import { SmPermissions } from "./abstractions.js";
export const SmPermissionsFeature = createPermissionsFeature(SM_PERMISSIONS_SCHEMA, SmPermissions);Register the feature in your context plugin:
import { SmPermissionsFeature } from "~/features/permissions/feature.js";
// In createContext:
SmPermissionsFeature.register(container);src/
├── domain/
│ └── permissionsSchema.ts # createPermissionSchema()
├── features/
│ └── permissions/
│ ├── abstractions.ts # createPermissionsAbstraction() + namespace type
│ └── feature.ts # createPermissionsFeature()
└── index.ts # SmPermissionsFeature.register(container)All methods follow a 3-tier bypass:
identityContext.hasFullAccess() → name: "*" permission (super admin)hasFullSchemaAccess() → wildcard permission (e.g. "sm.*")| Method | Purpose | Item-aware | Notes |
|---|---|---|---|
canAccess(entity, item?) | General access check | Yes | Without item: checks entity permission exists. With item + own: true: checks createdBy.id |
onlyOwnRecords(entity) | List filter flag | No | Returns true when ALL permissions have own: true |
canRead(entity) | Read permission | No | Checks rwd includes "r" (or no rwd = unrestricted) |
canCreate(entity) | Create permission | No | Checks rwd includes "w" |
canEdit(entity, item?) | Edit permission | Yes | With own: true + no item → allows (new/unsaved). With item → checks ownership |
canDelete(entity, item?) | Delete permission | Yes | With own: true + no item → RETURNS FALSE. Must pass item |
canPublish(entity) | Publish permission | No | Checks pw includes "p" |
canUnpublish(entity) | Unpublish permission | No | Checks pw includes "u" |
canAction(action, entity) | Custom boolean action | No | Checks permission[action] === true |
All return Promise<boolean>. Entity IDs are fully typed — canRead("bogus") produces a type error.
interface OwnableItem {
createdBy?: { id: string } | null;
}The Get use case is the central ownership gate — mutation use cases that delegate to GetById inherit ownership enforcement automatically.
import { Result } from "webiny/api";
import { GetByIdUseCase as UseCaseAbstraction, GetByIdRepository } from "./abstractions.js";
import { SmPermissions } from "~/features/permissions/abstractions.js";
import { NotAuthorizedError } from "~/domain/errors.js";
class GetByIdUseCaseImpl implements UseCaseAbstraction.Interface {
constructor(
private permissions: SmPermissions.Interface,
private repository: GetByIdRepository.Interface
) {}
async execute(id: string): UseCaseAbstraction.Return {
// 1. Entity-level read check
if (!(await this.permissions.canRead("product"))) {
return Result.fail(new NotAuthorizedError());
}
// 2. Fetch
const result = await this.repository.execute(id);
if (result.isFail()) {
return result;
}
// 3. Item-level ownership check
if (!(await this.permissions.canAccess("product", result.value))) {
return Result.fail(new NotAuthorizedError());
}
return result;
}
}
export const GetByIdUseCase = UseCaseAbstraction.createImplementation({
implementation: GetByIdUseCaseImpl,
dependencies: [SmPermissions, GetByIdRepository]
});import { IdentityContext } from "webiny/api/security";
class ListUseCaseImpl implements UseCaseAbstraction.Interface {
constructor(
private permissions: SmPermissions.Interface,
private identityContext: IdentityContext.Interface,
private repository: ListRepository.Interface
) {}
async execute(params: UseCaseAbstraction.Params): UseCaseAbstraction.Return {
if (!(await this.permissions.canRead("product"))) {
return Result.fail(new NotAuthorizedError());
}
const where = { ...params.where };
// Filter to own records if needed
if (await this.permissions.onlyOwnRecords("product")) {
const identity = this.identityContext.getIdentity();
where.createdBy = identity.id;
}
return this.repository.execute({ ...params, where });
}
}
// Dependencies must include IdentityContext
dependencies: [SmPermissions, IdentityContext, ListRepository];Important: The list where type must include createdBy?: string. For CMS-based entities, CmsEntryListWhere already has this.
class UpdateUseCaseImpl implements UseCaseAbstraction.Interface {
constructor(
private permissions: SmPermissions.Interface,
private getById: GetByIdUseCase.Interface,
private repository: UpdateRepository.Interface
) {}
async execute(id: string, data: UpdateData): UseCaseAbstraction.Return {
// 1. Entity-level edit check (no item yet)
if (!(await this.permissions.canEdit("product"))) {
return Result.fail(new NotAuthorizedError());
}
// 2. Fetch original (enforces canRead + canAccess via GetById)
const getResult = await this.getById.execute(id);
if (getResult.isFail()) {
return getResult;
}
const original = getResult.value;
// 3. Item-level edit check (defense in depth)
if (!(await this.permissions.canEdit("product", original))) {
return Result.fail(new NotAuthorizedError());
}
// ... events + repository
}
}canDelete with own: true and no item returns false.
Unlike canEdit (which returns true for own: true + no item), canDelete requires the item to verify ownership. The delete use case MUST fetch the item first.
class DeleteUseCaseImpl implements UseCaseAbstraction.Interface {
async execute(params: Params): UseCaseAbstraction.Return {
// Fetch first (enforces canRead + canAccess via GetById)
const getResult = await this.getById.execute(params.id);
if (getResult.isFail()) {
return Result.fail(getResult.error);
}
const item = getResult.value;
// Item-level delete check — MUST pass the item
if (!(await this.permissions.canDelete("product", item))) {
return Result.fail(new NotAuthorizedError());
}
// ... events + repository
}
}class PublishUseCaseImpl {
async execute(params: Params): UseCaseAbstraction.Return {
// 1. Entity-level publish check
if (!(await this.permissions.canPublish("product"))) {
return Result.fail(new NotAuthorizedError());
}
// 2. Fetch (enforces ownership via GetById)
const getResult = await this.getById.execute(params.id);
if (getResult.isFail()) {
return getResult;
}
// 3. Item-level ownership check (defense in depth)
if (!(await this.permissions.canAccess("product", getResult.value))) {
return Result.fail(new NotAuthorizedError());
}
// ... events + repository
}
}The permissions abstraction is passed directly as a dependency — it IS the DI key:
export const MyUseCase = UseCaseAbstraction.createImplementation({
implementation: MyUseCaseImpl,
dependencies: [SmPermissions, OtherDep]
});Note: Use SmPermissions directly (not SmPermissions.Abstraction). The abstraction returned by createPermissionsAbstraction is the DI key itself.
canDelete without item + own: true = false — Always pass the item to canDelete. Fetch first, then check.canEdit without item + own: true = true — Intentional: allows editing new/unsaved records.canAccess without item = true — Only checks entity-level access, not ownership.where interface includes createdBy?: string for own-scope filtering.dependencies array order exactly.SmPermissions directly in dependencies, not SmPermissions.Abstraction.The API schema and the admin-side createPermissionSchema should use the same prefix, entity IDs, and action names. This ensures the permissions emitted by the admin UI are correctly evaluated by the API.
API: createPermissionSchema({ prefix: "sm", entities: [{ id: "product", permission: "sm.product", ... }] })
Admin: createPermissionSchema({ prefix: "sm", entities: [{ id: "product", permission: "sm.product", ... }] })See webiny-admin-permissions for the admin-side implementation.
484553b
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.