ADR-005: Access control
Table of contents
Status
Accepted
Date
2026-01-03
Context
Secrets in the vault may contain highly sensitive data (API keys, passwords, certificates). Access to these secrets must be controlled to:
- Prevent unauthorized access to sensitive data
- Support collaborative workflows (teams, departments)
- Integrate with TYPO3's existing permission system
- Enable audit trails for compliance
Problem statement
How should access to vault secrets be controlled in a way that integrates naturally with TYPO3's backend user system?
Decision drivers
- TYPO3 integration: Use existing backend users and groups
- Granularity: Per-secret permissions, not just global
- Simplicity: Familiar model for TYPO3 administrators
- Flexibility: Support owner, group, and admin access patterns
- Auditability: All access attempts must be logged
Considered options
Option 1: TYPO3 page-based permissions
Inherit permissions from the page tree where secrets are stored.
Pros:
- Familiar TYPO3 pattern
- Works with existing mount points
Cons:
- Secrets aren't naturally page-based
- Complex for cross-page secrets
- Inflexible for API-created secrets
Option 2: Custom ACL system
Build a separate permission system specific to vault.
Pros:
- Maximum flexibility
- Could model complex scenarios
Cons:
- Learning curve for administrators
- Doesn't leverage existing TYPO3 knowledge
- More code to maintain
Option 3: Owner/Group model with TYPO3 integration
Each secret has an owner (backend user) and allowed groups (backend groups).
Pros:
- Maps to TYPO3 concepts (users, groups)
- Simple mental model: "who owns it, who can access it"
- Familiar to Unix-style permissions
Cons:
- Less granular than full ACL
- No per-operation permissions (read vs write)
Decision
We chose Owner/Group model with TYPO3 integration because:
- Familiarity: TYPO3 administrators understand users and groups
- Simplicity: Easy to reason about access decisions
- Sufficient granularity: Owner + groups covers most use cases
- Admin override: TYPO3 admins can access all secrets (expected behavior)
Implementation
Permission model
Access Decision Tree:
0. Does the actor hold the operation permission for what it is about to do
(secret.use / secret.reveal / secret.create / secret.rotate /
secret.delete / secret.manage_policy)?
→ NO: DENY. This gate is independent of everything below it; the
per-secret tiers can never grant an operation the actor may not perform.
1. Is user a TYPO3 admin or system maintainer?
→ YES: ALLOW (full access) — UNLESS the hardened profile withdrew the
bypass (disableAdminOverride) and no break-glass window is open.
2. Is user the secret's owner (owner_uid)?
→ YES: ALLOW (full access)
3. Is user a member of the secret's group tiers?
→ READ: member of allowed_groups OR write_groups → ALLOW
→ WRITE: member of write_groups → ALLOW
→ DELETE: no group tier applies — owner or admin only
4. Is this a CLI/scheduler context with CLI access enabled?
→ YES: Check CLI access groups
→ Group matches: ALLOW
5. Is this frontend context with frontend_accessible=true?
→ YES: ALLOW (read only). A frontend request holds NO operation
permission at all, whatever backend session the visitor carries —
TYPO3 populates $GLOBALS['BE_USER'] for any visitor with a valid
backend session, and frontend output is page-cached.
6. Default: DENY
Database schema
-- Single owner
owner_uid int(11) unsigned DEFAULT 0 NOT NULL,
-- Two group tiers (many-to-many). allowed_groups grants READ;
-- write_groups grants read AND write. Neither grants delete.
allowed_groups text,
write_groups text,
-- Frontend access flag
frontend_accessible tinyint(1) unsigned DEFAULT 0 NOT NULL,
-- Permission scoping
context varchar(50) DEFAULT '' NOT NULL,
scope_pid int(11) unsigned DEFAULT 0 NOT NULL,
-- Many-to-many relation tables, one per tier
CREATE TABLE tx_nrvault_secret_begroups_mm (
uid_local int(11) unsigned, -- Secret UID
uid_foreign int(11) unsigned, -- Backend group UID (read tier)
);
CREATE TABLE tx_nrvault_secret_writegroups_mm (
uid_local int(11) unsigned, -- Secret UID
uid_foreign int(11) unsigned, -- Backend group UID (write tier)
);
AccessControlService
final readonly class AccessControlService implements AccessControlServiceInterface
{
public function canRead(Secret $secret): bool
{
return $this->checkAccess($secret, self::PERMISSION_READ);
}
private function checkAccess(Secret $secret, string $permission): bool
{
$backendUser = $GLOBALS['BE_USER'] ?? null;
if ($backendUser === null) {
return $this->checkCliAccess($secret);
}
// THE single admin-bypass seam. Never inline isAdmin() or
// isSystemMaintainer() in a caller: an override that is only
// half-disabled is worse than one that is not disabled at all,
// because the deployment believes it is protected.
if ($this->adminBypassActive($backendUser->isAdmin())) {
return true;
}
// Owner has full access
$userUid = (int) ($backendUser->user['uid'] ?? 0);
if ($userUid === $secret->getOwnerUid()) {
return true;
}
// Group tiers are per-permission: read reads both tiers, write
// reads write_groups only, delete has no group tier at all.
$secretGroups = $this->secretGroupsForPermission($secret, $permission);
return array_intersect($this->currentUserGroups(), $secretGroups) !== [];
}
}
Under
Security with disableAdminOverride set,
admin denies a real administrator unless a break-glass
window is open. That is the whole reason the bypass has exactly one
implementation.
Enforcement points
Access checks are enforced in
Vault, and every enforcement point
combines both gates rather than either one alone.
A read asserts the per-secret tier via
can and the
secret.use operation permission; a reveal additionally asserts
secret.reveal. A write asserts
can plus secret.create or
secret.rotate depending on whether the secret already exists, and
secret.manage_policy when the submitted data changes the owner or the
group tiers. A delete asserts
can plus secret.delete.
Every denial writes an access_denied audit row before the
Access leaves the service, so a refusal is evidence
rather than a silent gap.
TCA configuration
'owner_uid' => [
'label' => 'Owner',
'config' => [
'type' => 'group',
'allowed' => 'be_users',
'maxitems' => 1,
],
],
'allowed_groups' => [
'label' => 'Allowed Groups (read)',
'config' => [
'type' => 'group',
'allowed' => 'be_groups',
'MM' => 'tx_nrvault_secret_begroups_mm',
'maxitems' => 20,
],
],
'write_groups' => [
'label' => 'Write Groups (read + write)',
'config' => [
'type' => 'group',
'allowed' => 'be_groups',
'MM' => 'tx_nrvault_secret_writegroups_mm',
'maxitems' => 20,
],
],
Actor context
public function getCurrentActorUid(): int
{
return (int) ($GLOBALS['BE_USER']->user['uid'] ?? 0);
}
public function getCurrentActorType(): string
{
if (Environment::isCli()) {
return 'cli';
}
if ($GLOBALS['BE_USER'] ?? null) {
return 'backend';
}
return 'api';
}
Field-level permissions (TSconfig)
Additional field-level control via TSconfig:
vault.permissions {
default {
reveal = 1
copy = 1
edit = 1
readOnly = 0
}
tx_myext_settings.api_key {
reveal = 0
copy = 0
}
}
reveal and copy only affect the rendered form element. edit and
readOnly are additionally enforced on the DataHandler write path for TCA
vault fields: a value submitted for a protected field is discarded and reported
to the editor, so stripping the readonly attribute in the browser gains
nothing. Two limits remain: the settings are read from the global (page 0)
TSconfig rather than from the edited record's page, and vault fields embedded in
FlexForms — which resolve their permissions under the FlexForm column name —
are not re-checked on write.
Consequences
Positive
- Familiar model: Uses TYPO3 users and groups
- Simple reasoning: Owner and group membership are clear concepts
- Admin override: Expected TYPO3 behavior preserved
- Audit integration: All access attempts logged with actor info
- Flexible scoping: Context and scope_pid for additional filtering
Negative
- No per-operation ACL: Read/write/delete not separately controlled.
Superseded. Ten operation permissions now exist as
Vaultcases (Permission secret.use,secret.reveal,secret.create,secret.rotate,secret.delete,secret.manage_policy,audit.view,audit.export,master_key.rotate,vault.configure), each granted per backend user group through thetx_nrvault:<permission>custom option and enforced centrally viaAccess. They are a second gate alongside the per-secret tiers described here, not a replacement for them. See Operation permissions.Control Service Interface:: is Granted () - Group proliferation: May need many groups for fine-grained control
- No inheritance: Secrets don't inherit from parent pages
Risks
- Orphaned secrets if owner is deleted
- Group changes affect access immediately (no caching)
Mitigation
- Default to admin ownership for orphaned secrets
- Document group membership implications
- Provide cleanup commands for orphaned secrets