Skip to content

feat(api-aco): code-defined folder-level permissions via FlpFactory - #5620

Open
adrians5j wants to merge 6 commits into
nextfrom
claude/code-defined-flps
Open

feat(api-aco): code-defined folder-level permissions via FlpFactory#5620
adrians5j wants to merge 6 commits into
nextfrom
claude/code-defined-flps

Conversation

@adrians5j

Copy link
Copy Markdown
Member

What

Roles and teams can already be defined in code, via RoleFactory and TeamFactory. Folder-level permissions could not — they existed only as per-folder records assigned in the Admin UI. This adds the missing third leg.

import { FlpFactory } from "webiny/api/aco/flp";

class MyFolderPermissionsImpl implements FlpFactory.Interface {
    async execute(): FlpFactory.Return {
        return [
            {
                type: "cms:article",
                path: "/marketing/*",   // folder + subtree; omit the "/*" for an exact match
                permissions: [{ target: "team:content-team", level: "editor" }]
            }
        ];
    }
}

export default FlpFactory.createImplementation({
    implementation: MyFolderPermissionsImpl,
    dependencies: []
});

Rules match on the folder's type and path. The stored path form is root/<slug>/<slug>, so the leading root segment is optional and /marketing, marketing, and root/marketing are all accepted.

A runnable example ships in extensions/MyFolderPermissions.ts, next to the existing MyRole.ts and MyTeam.ts.

How

Two decorators — over GetFlpUseCase and ListFlpsUseCase. Both were chosen because they return records carrying path and type (the fields rules match on), and both sit upstream of getDefaultPermissions, so team: targets are expanded to the current identity by the existing GetDefaultPermissionsWithTeams with no new team-resolution code.

Both are needed, not one: ListFoldersWithFolderLevelPermissions goes through listFolderLevelPermissions and never touches getFolderLevelPermissions, so decorating only GetFlp would give correct single-folder checks and wrong folder listings.

FlpProvider mirrors TeamProvider — fans out over all registered factories, caches per container.

Code permissions are never persisted

Four layers, the first structural:

  • UpdateFlpUseCase and CreateFlpUseCase reach flpCrud directly rather than through the decorated use cases, so the inheritance-sync write path cannot see code permissions at all — it can't write them into subtree records.
  • plugin is deliberately absent from AcoFolder_PermissionsInput, so the API cannot accept it.
  • Permissions.create drops plugin entries. Every FLP write funnels through it, which matters because a client echoing back what it read would otherwise turn a code permission into a stored one nobody can remove.
  • UpdateFolder rejects a manually set plugin with a clear error, for programmatic callers.

Precedence

no-access needed no new logic. DefaultPermissionsMerger already gives it absolute priority when collapsing per-identity permissions, so a code rule denying access can't be overridden from the Admin UI and is inherited down the subtree. Other levels follow the existing owner > editor > viewer resolution, which makes code permissions a hard floor for denial and additive otherwise. Full-access users bypass code FLPs exactly as they bypass stored ones.

For an identical target, the code permission wins — code entries are placed first and the stored entry for that target is dropped.

Licensing

CodeFlpsFeature checks advancedAccessControlLayer.folderLevelPermissions at register time, following the pattern from #5609. Without the entitlement nothing is registered, so a code rule is never enforced. A registered FlpFactory on an unlicensed project is silently inert.

Admin UI

Code-defined permissions render locked, with "Defined in code and cannot be changed here." — level dropdown and remove both disabled. The update gateway and UseCaseWithoutInheritedPermissions strip them before sending, so a save never trips the API guard.

Testing

  • folder.flp.codeFlps.test.ts — merge-on-read with team expansion, folder-type isolation, subtree no-access denying folder + child, exact-match not leaking to children, and read-then-write-back neither persisting nor duplicating the code permission.
  • codeFlpPath.test.ts — path normalization and matching, including the marketing vs marketing-archive prefix-sibling case.
  • packages/api-aco 85 passed; app-aco 93 passed; api-headless-cms-aco / api-file-manager-aco / shared-aco green. Full monorepo build, lint, adio, and sync-dependencies all clean.

One fixture note: team membership reads from the stored admin-user record via ListUserTeamsUseCase, which these tests don't create, so identity B's team lookup is stubbed. Everything downstream — the actual code-FLP → team → identity expansion — is the real path.

Not included

Docs. A /docs/security/folder-level-permissions page mirroring the shape of /docs/security/teams is the natural follow-up.

🤖 Generated with Claude Code

Roles and teams can already be defined in code, via `RoleFactory` and
`TeamFactory`. Folder-level permissions could not — they existed only as
per-folder records assigned in the Admin UI. This adds the missing third leg.

An `FlpFactory` contributes rules matched on a folder's `type` and `path`,
where a trailing `/*` matches the folder plus its subtree. Matching rules are
merged into FLP records on read, by decorators over `GetFlpUseCase` and
`ListFlpsUseCase` — both return records carrying `path` and `type`, and both
sit upstream of `getDefaultPermissions`, so `team:` targets are expanded to the
current identity by the existing team resolution with no new code.

Code permissions are never persisted:

- `UpdateFlpUseCase` and `CreateFlpUseCase` reach `flpCrud` directly rather
  than through the decorated use cases, so the inheritance-sync write path
  structurally cannot see them.
- `plugin` is absent from `AcoFolder_PermissionsInput`, so the API cannot
  accept it.
- `Permissions.create` drops `plugin` entries — every FLP write funnels
  through it.
- `UpdateFolder` rejects a manually set `plugin` for programmatic callers.

`no-access` needed no new precedence logic: `DefaultPermissionsMerger` already
gives it absolute priority when collapsing per-identity permissions, so a code
rule denying access cannot be overridden from the UI. Other levels follow the
existing owner > editor > viewer resolution.

Registration is gated on `advancedAccessControlLayer.folderLevelPermissions`
at register time, so nothing is wired up or enforced without the entitlement.

In the Admin UI, code-defined permissions render locked, and the update path
strips them before sending.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown

🚓 Slop Cop

✅ Nothing worth flagging. The diff looks consistent with the PR's stated intent and the code-style rules.

The PR is coherent with its stated intent: it adds code-defined folder-level permissions via FlpFactory, touching only api-aco/app-aco/shared-aco/webiny packages with additive changes and one clean rename/refactor of an existing decorator; no integrity or style issues found.

Automated, non-blocking heads-up from an LLM. It can be wrong — use your judgment. Regenerates on every push.

adrians5j and others added 5 commits August 31, 2026 18:03
`target: "team:content-team"` leaked the internal wire format into the authoring
API, and the user form was worse — `admin:<id>`, where nothing in the name says
"user".

A permission now names its target directly, as `{ team: "content-team" }` or
`{ user: "<id>" }`, and `CodeFlpTarget` generates the `team:` / `admin:` string.
The type is a union with `user?: never` / `team?: never`, so specifying both, or
neither, or the old `target` form is a compile-time error. `CodeFlpTarget` also
throws on both/neither for callers coming from plain JS: silently dropping a
permission that denies access would mean accidental exposure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rename `FlpProvider.getFlps()` to `getCodeFlps()` so it is clear the
method returns code-defined FLPs collected from registered `FlpFactory`
instances, and not folder-level permissions in general.

Rename `UpdateFolderUseCaseWithoutInheritedPermissions` to
`UpdateFolderUseCaseWithoutReadOnlyPermissions` — the decorator strips
both inherited and code-defined permissions, so the old name only
described half of what it does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The provider only resolves permissions contributed by an `FlpFactory` —
FLPs stored in the database are loaded by the use cases it decorates.
Rename the abstraction, its implementation and the consumer fields so the
boundary is explicit, and document it on the abstraction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The stored-permission filter in `CodeFlpMerger.merge` looked like cosmetic
deduplication. Document that it is load-bearing: `DefaultPermissionsMerger`
reduces every permission for an identity by access level, so a leftover
duplicate could outrank the code-defined entry that shadows it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Authoring a code-defined FLP required hand-writing the folder type string
(`FmFile`, `cms:<modelId>`), which leaks an internal encoding into user
code and is easy to typo.

Expose `FlpFactory.FolderType.files()` and
`FlpFactory.FolderType.cmsEntries(modelId)` instead. `CodeFlp.type` stays
a plain `string`, so apps that register their own folder types can keep
passing those directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant