Security — Permissions, Rules, and Grantees¶
Overview and Motivation¶
Tentackle ships a complete, ACL-based authorization subsystem that is woven into the PDO pattern rather than bolted on beside it. Its job is to answer one question, consistently, on every tier of the application:
May this grantee perform this permission on this object (or class) within this domain context?
Two design choices make it distinctive:
- Authorization is data, not code. The access rules themselves are ordinary
PDOs — instances of the
Securityentity stored in thesecrulestable. They can be created, edited, queried, and shipped between JVMs like any other entity, and edited at runtime through a UI. No redeploy is needed to change who may do what. - Enforcement is automatic where it matters most. Because the persistence layer
knows every PDO's class, id, aggregate root,
and
DomainContext, it can apply the rules duringselect,persist, anddelete— without the application asking. A PDO the current user may not read is simply never handed to them; the framework behaves as if the row did not exist.
The whole subsystem lives in org.tentackle.security (in tentackle-pdo), with the
generated persistence/domain halves of the rule entity in tentackle-persistence
and tentackle-domain, and the editing UI in tentackle-fx-rdc.
| Artifact | Role |
|---|---|
Permission |
An action that can be granted or denied (an interface; permissions imply each other by extension). |
SecurityManager |
Evaluates a permission request against the rules and returns a SecurityResult. |
SecurityResult |
The verdict: isAccepted(), isDefault(), and a human-readable explain(...). |
Security |
One ACL rule, stored as a PDO in secrules. |
GranteeDescriptor |
Identifies who a rule applies to (a user or group entity, or "all"). |
SecurityFactory |
SPI entry point: the singleton SecurityManager and the predefined permissions. |
@Secured |
Method annotation that enforces permissions via an interceptor. |
@PermissionService |
SPI annotation registering a permission implementation. |
Permissions and Their Hierarchy¶
A permission is an interface, not an enum value. Permissions imply one another
by interface extension: granting a permission grants everything it extends.
Tentackle predefines six in
org.tentackle.security.permissions:
ReadPermission ("READ")
▲ ▲
│ │
WritePermission ViewPermission
("WRITE") ("VIEW")
▲ ▲
└─────┬──────┘
EditPermission ("EDIT")
ExecutePermission ("EXEC") AllPermission ("ALL")
Because EditPermission extends WritePermission, ViewPermission and both extend
ReadPermission, granting EDIT implies WRITE, VIEW, and READ as well.
This is what Permission.isAllowedBy(...) / isDeniedBy(...) compute when a rule
fires.
The split between the business permissions and the presentation permissions is deliberate and important:
| Permission | Governs | Checked by |
|---|---|---|
| READ | whether the business logic, acting for the user, may load the data at all | persistence layer, during select |
| WRITE | whether the business logic may insert/update/delete | persistence layer, during persist/delete |
| VIEW | whether the user may see the data in the UI (READ alone is not enough) | UI, via isViewAllowed() |
| EDIT | whether the user may change the data in the UI intentionally | UI, via isEditAllowed() |
| EXEC | whether a service/operation method may be invoked | @Secured, remote dispatch |
| ALL | shorthand standing in for every permission | rule authoring |
A back-end batch job may legitimately need to READ/WRITE rows the interactive user is not allowed to VIEW/EDIT. Separating the two lets the same data be processed by logic while staying hidden from the screen — see cache.md → Security, which recommends
ViewPermission(notReadPermission) on cached entities for exactly this reason.
There is deliberately no isReadAllowed() on a PDO: a PDO the user cannot read
is never loaded, so there is no instance on which to ask the question.
Where Security Is Enforced¶
There are three enforcement points. The first is automatic; the other two are declarative.
1. Automatically in the persistence layer (PDOs)¶
This is the backbone. Every load and store consults the
SecurityManager through
SecurityFactory.getInstance():
- Reading a root entity. When a PDO is selected through its
root context,
assertRootContextIsAccepted()evaluatesREADon the root; denial throws aSecurityException. - Reading components — aggregate-aware. Because every component knows its root
without a query, the layer evaluates the root's
READrule when a component is read. If the root is not readable, the component "does not exist" for this user andselectreturnsnull. A permission expressed once on the aggregate root therefore governs its whole component tree — no per-component rules needed. - Writing.
persist()/delete()checkWRITEon the root entity (isWriteAllowed()). Only root entities are writable; components are saved through their root, which is the object actually checked. - Class-level pre-checks.
isReadAllowed(context)/isWriteAllowed(context)let code (andselectAll) check a whole class before issuing a query.
The application normally writes none of this; it falls out of the ordinary
select/persist/delete calls. The rule entity itself
(SecurityPersistenceImpl)
is always readable, otherwise the manager could never load its own rules.
2. Declaratively on methods with @Secured¶
Any method of a PDO interface or an Operation interface can be
guarded by @Secured. The
SecuredInterceptor
(an interceptor) evaluates
the listed permissions against the object's domain context before the method body
runs, throwing SecurityException on denial:
public interface InvoiceDomain extends DomainObject<Invoice> {
@Secured(EditPermission.class) // caller needs EDIT on this invoice
@Transaction void approve();
@Secured({EditPermission.class, BookkeeperPermission.class}, ored = true)
@Transaction void postToLedger(); // EDIT *or* a custom Bookkeeper permission
}
Annotation options:
| Element | Meaning |
|---|---|
value() |
The required permission interfaces. |
ored |
false (default) → all must be accepted (AND); true → at least one (OR). |
explicit |
false (default) → accepted unless a rule denies; true → a rule must explicitly grant it (a defaulted accept counts as denial — see SecurityResult.isDefault()). |
For Operations (table-less services), the interceptor evaluates the
permission against the operation's effective class; this is the natural home for
ExecutePermission on service entry points.
3. In the UI¶
The UI asks the PDO directly via the presentation predicates on
PersistentObject:
isViewAllowed()— show the object / field at all?isEditAllowed()— let the user change it, or render it read-only?isWriteAllowed(),isPermissionAccepted(permission),getSecurityResult(permission)for finer control (and to surfaceexplain(...)as a tooltip).
The tentackle-fx-rdc binding
layer uses these to enable/disable widgets automatically, and ships a
SecurityEditor / SecurityRulesView so administrators can manage rules from
within the application.
The Rule Model¶
A Security rule (table
secrules, class id 5) is an ACL entry. Its fields answer who, on what,
where, and what:
| Field group | Columns | Meaning |
|---|---|---|
| Secured object | objectClassName, objectClassId, objectId |
The protected target. A non-PDO class (by name), a PDO class (objectClassId, objectId = 0 → all instances), or one PDO instance (objectId ≠ 0). |
| Grantee | granteeClassId, granteeId |
The user/group entity the rule applies to. 0/0 means all grantees. |
| Context | domainContextClassId, domainContextId |
Optional restriction to a domain context entity (e.g. a tenant). 0/0 → any context. |
| Decision | permissions, allowed, message, priority |
A comma-separated permission list, the allow/deny flag, an optional i18n explanation, and the evaluation order. |
How a request is evaluated¶
The DefaultSecurityManager
treats the rule set as an access control list walked in priority order, first
match wins:
- Determine the grantee from the session's user
(
SessionInfo.getUserId()/getUserClassId()). A system user (userId ≤ 0) bypasses all checks — every permission is granted. - Determine the grantees to check — by default the user itself plus the
wildcard
0/0("all"). OverridedetermineGranteesToCheck(...)to expand this to the user's groups/roles (see below). - Select the candidate rules for the target, sorted by
priority(0 = highest / evaluated first). Three separate indexes exist for the three target kinds: instance rules, PDO-class rules, and non-PDO-class rules. - Fire the first matching rule. A rule matches when its context applies
(
DomainContext.isWithinContext(...)) and the requested permission is implied by the rule's permission list (isAllowedByfor allow-rules,isDeniedByfor deny-rules). Itsallowedflag becomes the verdict. - Fall back. For an instance request with no matching instance rule, the
manager retries at the class level (
objectId = 0). If still nothing fires, the default applies.
SecurityResult r = SecurityFactory.getInstance().getSecurityManager()
.evaluate(context, SecurityFactory.getInstance().getReadPermission(),
classId, objectId);
if (!r.isAccepted()) {
throw new SecurityException(session, r.explain("not allowed to read"));
}
Accept-by-default vs. deny-by-default¶
SecurityManager.setAcceptByDefault(...) chooses the model when no rule fires:
- accept by default (the default) → a blacklist: everything is permitted unless a rule denies it. Add deny-rules for the exceptions.
- deny by default → a whitelist: nothing is permitted unless a rule grants it. Add allow-rules for what is permitted.
Setting both accept and deny to false yields a "neither accepted nor denied"
result, which the explicit = true mode of @Secured can require to be turned into
an explicit grant.
The manager is disabled by default and must be enabled at startup (
setEnabled(true)). While disabled, every request is granted regardless of the default — convenient for tests and tooling, and the reason production startup code must remember to enable it.
Grantees, Users, and Groups¶
A grantee is identified by a GranteeDescriptor
— a (classId, id) pair, usually constructed from a user or group PDO. The
session carries the current user's id and class id; determineGrantee(context) turns
them into the descriptor.
Out of the box a user is checked against rules for itself and for all. To support
roles or groups, subclass DefaultSecurityManager and override
determineGranteesToCheck(...) to add the descriptors of every group the user
belongs to:
@Override
protected Collection<GranteeDescriptor> determineGranteesToCheck(
DomainContext context, GranteeDescriptor grantee) {
Collection<GranteeDescriptor> grantees = super.determineGranteesToCheck(context, grantee);
User user = Pdo.create(User.class, context).select(grantee.getGranteeId());
for (Group g : user.getGroups()) {
grantees.add(new GranteeDescriptor(g)); // rules granted to the group now apply
}
return grantees;
}
The per-grantee expansion is memoized in the manager's granteeMap, so the group
lookup happens once per distinct user, not on every check.
Context Scoping and Multi-Tenancy¶
Because each rule may carry a domainContext target and each PDO carries its
DomainContext, permissions can be scoped to a
logical data space. A rule restricted to context Tenant 42 fires only when the
object being checked lives within that context (isWithinContext(...)). This dovetails
with Tentackle's multi-tenancy: a single
rule set can express "user X is a bookkeeper in tenant A but only a viewer in
tenant B" without duplicating entities. On the middle tier,
DomainContext.assertPermissions() additionally verifies the incoming context is
allowed for the connected user (see pdo.md → Sessions, immutability, and remote
safety).
Custom Permissions¶
Define a new permission by writing an interface (extend an existing permission to
inherit its implication) and an implementation annotated with
@PermissionService so the
ServiceFinder discovers it:
public interface BookkeeperPermission extends EditPermission { // implies EDIT/WRITE/VIEW/READ
String NAME = "BOOKKEEPER";
}
@PermissionService(BookkeeperPermission.class)
public final class BookkeeperPermissionImpl
extends AbstractPdoPermission implements BookkeeperPermission {
@Override public String getName() { return NAME; }
}
The name ("BOOKKEEPER") is what appears in a rule's comma-separated permissions
column; SecurityFactory.getPermissionInterface(name) maps it back to the interface
at evaluation time. Custom permissions are first-class: usable in @Secured, in rule
data, and in isPermissionAccepted(...).
Rules Are PDOs — Lifecycle and Coherency¶
Treating rules as PDOs has concrete operational consequences:
- They are cached.
DefaultSecurityManagerloads the whole rule set withselectAllCached()into in-memory ACL maps, so evaluation is pure CPU work with no database round-trip (cache.md). - They self-invalidate. The manager registers a
ModificationListeneron theSecurityclass. Any insert/update/delete of a rule — from anywhere in the cluster — callsinvalidate(), and the next request lazily rebuilds the maps. Rule changes thus take effect without a restart and stay coherent across JVMs. - They are remoting-aware. A remote client's manager initializes against the
server (
assertRemoteSecurityManagerInitialized()); evaluation and rule loading dispatch over TRIP like any PDO operation. Remote service invocation is itself gated byExecutePermissionbefore dispatch. - They can go stale. Since the model has no FK from a rule to its target,
deleting a secured object or a grantee can orphan a rule.
removeObsoleteRules(session)sweeps rules whose object or grantee no longer exists.
Pluggability¶
SecurityManager is an SPI obtained through
SecurityFactory. The
DefaultSecurityManager
(ACL-on-secrules) is only the default. An application may:
- subclass it to add groups, custom defaults, or extra rule kinds (override the
determine…/create…SecurityResulthooks); or - replace it wholesale with a manager backed by LDAP, an external IAM/policy
engine, or hard-coded policy — the
Securityentity is then unused. Everything upstream (@Secured, persistence checks,isViewAllowed(), the UI) is written against theSecurityManagerinterface and keeps working unchanged.
Use Cases at a Glance¶
| Goal | How |
|---|---|
| Hide a whole entity type from a user | Deny READ (or VIEW) rule on the PDO class for that grantee. |
| Hide one record | Deny rule on the PDO instance (objectId ≠ 0). |
| Read-only screen for some users | Grant VIEW but not EDIT; the UI renders fields read-only via isEditAllowed(). |
| Let a batch process data users can't see | Grant READ/WRITE to the batch user, withhold VIEW/EDIT. |
| Protect a business operation | @Secured(...) on the domain/operation method. |
| "At least one of" several rights | @Secured({A.class, B.class}, ored = true). |
| Require an explicit grant (whitelist a method) | @Secured(value = …, explicit = true) and/or deny-by-default. |
| Per-tenant authorization | Scope rules to a domainContext target. |
| Roles/groups | Override determineGranteesToCheck(...). |
| App-specific right | Custom Permission interface + @PermissionService impl. |
| Change policy without redeploy | Edit Security rules (e.g. via the RDC SecurityEditor); caches invalidate automatically. |
| Swap the whole mechanism | Provide an alternative SecurityManager via SecurityFactory. |
| Lock everything down by default | setAcceptByDefault(false) and grant explicitly. |
| Grant system/superuser access | Run under a session whose userId ≤ 0. |
Summary¶
| Concept | What it gives you |
|---|---|
| Permissions as extensible interfaces | Implication for free (EDIT ⇒ WRITE/VIEW/READ); add your own. |
| Read/Write vs. View/Edit split | Business-logic access decoupled from UI presentation. |
Rules as PDOs (secrules) |
Authorization is editable data — cached, coherent across JVMs, no redeploy. |
| Automatic persistence-layer checks | Unauthorized rows are never loaded or written; nothing for the app to wire. |
| Aggregate-aware enforcement | One rule on the root governs its whole component tree, with no extra query. |
@Secured interceptor |
Declarative method-level guards with AND/OR and explicit-grant modes. |
| Context-scoped rules | Per-tenant / per-data-space authorization. |
| Grantee expansion hook | Users, groups, and roles. |
SecurityManager SPI |
Replace or extend the entire mechanism (LDAP, IAM, custom). |
See Also¶
- pdo.md — PDOs, the
DomainContext, and the aggregate roots the rules key on. - cache.md — why cached entities should use
ViewPermission, notReadPermission. - operation.md — securing table-less service operations with
@Secured. - interceptors.md — the interception mechanism behind
@Secured. - session.md — the session/user the grantee is derived from, and the
ModificationTracker. - trip.md — how security evaluation and rule loading work across tiers.
- services.md — the SPI discovery used for permissions and the security manager.
- rdc.md — the
SecurityEditorUI for managing rules.