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 exercise this permission on this object (or class) within this domain context?
The complementary question — who is this grantee in the first place? — is answered once, at login,
by Authentication. By the time any
rule is evaluated the identity is settled: the userId/userClassId pair on the session info is
what a grantee is derived from.
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.
The implication works the other way round for denials: denying READ denies VIEW,
WRITE, and EDIT too, since none of them can be granted without READ.
This is what Permission.isAllowedBy(...) / isDeniedBy(...) compute when a rule
fires. ALL is special: a rule granting or denying ALL matches every requested
permission, whereas a request for ALL is granted only by a rule granting ALL, but
denied by any deny-rule.
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. Cached entities rely on exactly this: a cache is shared by all users, so cached entities are always readable and
READrules do not apply to them — useViewPermissioninstead (see cache.md → Security).
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. Every root entity loaded from the database is checked for
READ. A denied root entity is silently dropped:selectreturnsnull, and lists simply don't contain it. - Reading within a root context. Selecting from within a
root context — which is how the components
of an aggregate are loaded — first evaluates
READon the context's root entity (assertRootContextIsAccepted()); denial throws aSecurityException. - Reading components — aggregate-aware. Because every component knows its root
without a query, a component loaded outside its root context (e.g. via a deep link)
is checked against the root's
READrule. 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.
save()/persist()/delete()checkWRITEon the root entity (isWriteAllowed()) and throw aSecurityExceptionif denied. Only root entities are writable; components are saved through their root, which is the object actually checked.
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. The same applies
to all cached entities.
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(BookkeeperPermission.class) // a custom permission, see below
@Transaction void postToLedger();
@Secured(value = {BookkeeperPermission.class, AuditorPermission.class}, ored = true)
Report createJournal(); // BOOKKEEPER *or* a custom AUDITOR 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 → the default context. |
| Decision | permissions, allowed, message, priority |
A comma-separated permission list, the allow/deny flag, an optional explanation shown to the user, and the evaluation order. |
A rule for a PDO class or instance must leave objectClassName empty, a rule for a non-PDO class
must set it, and every rule needs at least one permission — the
SecurityValidator rejects
anything else. Rules of the same grantee and target are evaluated by priority, rules with the
same priority by their object ID.
How a request is evaluated¶
The DefaultSecurityManager
treats the rule set as an access control list walked grantee by grantee and, within
a grantee, 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, followed by the
wildcard
0/0("all"). OverridedetermineGranteesToCheck(...)to expand this to the user's groups/roles (see below). The grantees are checked in this order: all rules of the user are tried before any rule for "all", whatever their priority. - Select the candidate rules of each grantee for the target, sorted by
priority(lowest value 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.
Either way, the result tells whether it was reached by default
(SecurityResult.isDefault()), which the explicit = true mode of @Secured uses to
require an explicit grant even when accepting by default.
The manager is disabled when created. While disabled, every request is granted regardless of the default — convenient for tests and tooling.
AbstractApplication.configure()enables it at startup, unless the propertynosecurityis given.
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 = new ArrayList<>();
grantees.add(grantee); // the user's own rules first
// systemContext: a context whose session belongs to a system user (userId <= 0)
User user = Pdo.create(User.class, systemContext).select(grantee.getGranteeId());
for (Group g : user.getGroups()) {
grantees.add(new GranteeDescriptor(g)); // rules granted to the group now apply
}
grantees.add(new GranteeDescriptor(0, 0)); // and the rules for all grantees last
return grantees;
}
The grantees are checked in the order of the returned collection, so the example places the groups between the user and "all". Two things to keep in mind:
- The method runs while a permission is being evaluated. Loading the groups with the user's own context would ask the manager again for the same user, which recurses into the manager. Hence, the example loads them with a system context, which is never checked.
- 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() can additionally verify that the incoming context is
allowed for the connected user, if the application overrides it — the default does nothing (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 base class decides what kind of object the permission applies to, through
Permission.appliesTo(Class):
| Base class | Applies to |
|---|---|
AbstractPdoPermission |
PDO classes — like the built-in READ, VIEW, WRITE and EDIT |
AbstractNonPdoPermission |
everything else: operations, remote services, UI features — like the built-in EXEC |
AbstractPermission |
implement appliesTo yourself; the built-in ALL applies to any class |
SecurityFactory.getPermissionInterfaces(clazz) returns only the permissions applicable to a class,
and the security rules editor
offers only those for the class whose rules are being edited. A BookkeeperPermission derived from
AbstractPdoPermission thus shows up for PDOs, but not for an operation. A custom permission guarding
a menu entry or an operation extends AbstractNonPdoPermission instead.
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 makes sure the server's
manager is initialized (
assertRemoteSecurityManagerInitialized()) and loads the rules from the server's cache over TRIP like any PDO. The evaluation itself always runs locally on the loaded rules. 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. Failures are logged, not thrown.
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.
When the identity comes from an external provider, the roles it asserted are available too:
RemoteDbSessionImpl.getAuthentication() carries the verified claims and groups of the login, which
is the natural input for a determineGranteesToCheck override that maps identity-provider groups
onto Tentackle grantees. See
Authentication.
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 themselves. |
| 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 are always readable and use
ViewPermissioninstead. - 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. - authentication.md — how that user is established at login: credentials, providers, single sign-on.
- trip.md — how the rules travel across tiers.
- services.md — the SPI discovery used for permissions and the security manager.
- rdc.md — the
SecurityEditorUI for managing rules.