API Permissions
SkillDev toolsAdds role-based access control to Webiny APIs so users can only read, edit, publish, or delete what they're allowed.
Available today. Use it from your connected AI after setup.
No other account needed.
Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.
Then ask your AI: use the API Permissions skill
About this skill
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 permiss
What this skill tells your AI
The instructions your AI receives, as published by webiny/webiny-js in skills/user-skills/api/permissions/SKILL.md and read by ahel’s review.
Overview
Permissions 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.
Layer 1: Domain — Permission Schema
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.
Schema Fields
| 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 |
Scopes
"full"— User can access all records (default when noownflag on permission object)"own"— User can only access records wherecreatedBy.id === identity.id
Simple Apps (No Entities)
Omit entities for binary full/no access:
export const MA_PERMISSIONS_SCHEMA = createPermissionSchema({
prefix: "ma",
fullAccess: true
});
Layer 2: Features — DI Artifacts + Registration
Abstraction (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>;
}
Feature (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);
Registration
Register the feature in your context plugin:
import { SmPermissionsFeature } from "~/features/permissions/feature.js";
// In createContext:
SmPermissionsFeature.register(container);
File Structure
src/
├── domain/
│ └── permissionsSchema.ts # createPermissionSchema()
├── features/
│ └── permissions/
│ ├── abstractions.ts # createPermissionsAbstraction() + namespace type
│ └── feature.ts # createPermissionsFeature()
└── index.ts # SmPermissionsFeature.register(container)
Permission Methods
All methods follow a 3-tier bypass:
identityContext.hasFullAccess()→name: "*"permission (super admin)hasFullSchemaAccess()→ wildcard permission (e.g."sm.*")- Entity-level permission check
Method Reference
| 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.
OwnableItem Interface
interface OwnableItem {
createdBy?: { id: string } | null;
}
Use Case Implementation Patterns
Get Use Case (Read + Ownership Gate)
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]
});
List Use Case (Read + Own Records Filter)
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.
Update Use Case (Edit + Item-Level Check)
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
}
}
Delete Use Case (CRITICAL: Item-Level Delete)
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
}
}
Publish Use Case (Publish + Ownership)
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
}
}
DI Injection
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.
Gotchas
canDeletewithout item +own: true=false— Always pass the item tocanDelete. Fetch first, then check.canEditwithout item +own: true=true— Intentional: allows editing new/unsaved records.canAccesswithout item =true— Only checks entity-level access, not ownership.- List where type — Ensure the
whereinterface includescreatedBy?: stringfor own-scope filtering. - Dependencies order — DI constructor params must match the
dependenciesarray order exactly. - Abstraction is the DI key — Use
SmPermissionsdirectly in dependencies, notSmPermissions.Abstraction.
Matching Admin-Side Permissions
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.
Related Skills
- webiny-admin-permissions — Admin-side permission UI and DI-backed permission checking
- webiny-api-architect — Architecture overview, Services vs UseCases, feature structure
- webiny-use-case-pattern — UseCase implementation, Result handling, decorators
- webiny-dependency-injection — Injectable services catalog
Signals
- GitHub stars
- 8k
- Forks
- 679
- Last commit
- Sep 2026
Advanced
- Item type
- skill
- Key
webiny-api-permissions- Source
- github.com/webiny/webiny-js