---
title: "ADR-005: Access control"
manual: "nr-vault"
version: "1.0"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-vault:adr-005-access-control@1.0"
source: "Developer/Adr/ADR-005-AccessControl.rst"
rendered: "2026-09-18T07:37:50+00:00"
---

# ADR-005: Access control {#adr-005-access-control-1}

**Table of contents**

-   [Status](https://docs.typo3.org/permalink/netresearch/nr-vault:status@1.0)
-   [Date](https://docs.typo3.org/permalink/netresearch/nr-vault:date@1.0)
-   [Context](https://docs.typo3.org/permalink/netresearch/nr-vault:context@1.0)
-   [Problem statement](https://docs.typo3.org/permalink/netresearch/nr-vault:problem-statement@1.0)
-   [Decision drivers](https://docs.typo3.org/permalink/netresearch/nr-vault:decision-drivers@1.0)
-   [Considered options](https://docs.typo3.org/permalink/netresearch/nr-vault:considered-options@1.0)
-   [Decision](https://docs.typo3.org/permalink/netresearch/nr-vault:decision@1.0)
-   [Implementation](https://docs.typo3.org/permalink/netresearch/nr-vault:implementation@1.0)
-   [Consequences](https://docs.typo3.org/permalink/netresearch/nr-vault:consequences@1.0)
-   [Related decisions](https://docs.typo3.org/permalink/netresearch/nr-vault:related-decisions@1.0)
-   [References](https://docs.typo3.org/permalink/netresearch/nr-vault:references@1.0)

## Status {#status}

Accepted

## Date {#date}

2026-01-03

## Context {#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 {#problem-statement}

How should access to vault secrets be controlled in a way that integrates
naturally with TYPO3's backend user system?

## Decision drivers {#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 {#considered-options}

### Option 1: TYPO3 page-based permissions {#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 {#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 {#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 {#decision}

We chose **Owner/Group model with TYPO3 integration** because:

1.  **Familiarity**: TYPO3 administrators understand users and groups
1.  **Simplicity**: Easy to reason about access decisions
1.  **Sufficient granularity**: Owner + groups covers most use cases
1.  **Admin override**: TYPO3 admins can access all secrets (expected behavior)

## Implementation {#implementation}

### Permission model {#permission-model}

**Access decision tree**

```text
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 {#database-schema}

**Access control columns**

```sql
-- 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 {#accesscontrolservice}

**Classes/Security/AccessControlService.php**

```php
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 `SecurityProfile::Hardened` with `disableAdminOverride` set,
`adminBypassActive()` denies a real administrator unless a break-glass
window is open. That is the whole reason the bypass has exactly one
implementation.

### Enforcement points {#enforcement-points}

Access checks are enforced in `VaultService` and, for the FormEngine
path, in `SecretTcaHook`. Every enforcement point combines **both** gates
rather than either one alone.

A read asserts the per-secret tier via `canRead()` and the
`secret.use` operation permission; a reveal additionally asserts
`secret.reveal`. A write asserts `canWrite()` 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 `canDelete()` plus `secret.delete`.

Every denial writes an `access_denied` audit row before the
`AccessDeniedException` leaves the service, so a refusal is evidence
rather than a silent gap.

A FormEngine edit of a `tx_nrvault_secret` record never reaches
`VaultService` for its metadata columns, so `SecretTcaHook` applies
the same two gates in `processDatamap_preProcessFieldArray()`, before
DataHandler writes anything. Core's `tables_modify` grant is a *table*
permission and is not the vault ACL: without this the holder of that grant
could change any secret's columns. The hook therefore refuses the whole
record — by nulling the by-ref field array, which makes DataHandler skip it —
unless `canWrite()` passes, and it drops the **privileged columns** from
an otherwise authorized save unless the actor is an administrator or the
owner holding `secret.manage_policy`:

-   **`owner_uid`, `scope_pid`, `allowed_groups`, `write_groups`**

    Who may reach the secret, and under which page scope.

-   **`frontend_accessible`**

    Flips the secret from ACL-gated to readable by any frontend request.

-   **`hidden`**

    The same column `SecretsController::toggleAction()` gates on
    `secret.manage_policy`; leaving the record path open would make that
    gate bypassable.

-   **`expires_at`**

    Honoured at runtime, so backdating denies the secret to every consumer
    and clearing it revives a retired one.

-   **`metadata`**

    Machine-consumed provenance that `OrphanCleanupTask` reads to decide
    whether a secret is an orphan to delete.

-   **`context`**

    The inventory dimension the listing and analytics filter on.

`description` is deliberately not privileged: it is free-text documentation
with no machine consumer, and write access to a secret should carry the right
to document it.

### TCA configuration {#tca-configuration}

**Configuration/TCA/tx_nrvault_secret.php**

```php
'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 {#actor-context}

**Getting current actor information**

```php
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) {#field-level-permissions-tsconfig}

Additional field-level control via TSconfig:

**TSconfig for field permissions**

```typoscript
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 {#consequences}

### Positive {#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 {#negative}

-   **No per-operation ACL**: Read/write/delete not separately controlled.
    **Superseded.** Ten operation permissions now exist as
    `VaultPermission` cases (`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 the `tx_nrvault:<permission>` custom option and enforced
    centrally via `AccessControlServiceInterface::isGranted()`. They are a
    second gate alongside the per-secret tiers described here, not a
    replacement for them. See [Operation permissions](https://docs.typo3.org/permalink/netresearch/nr-vault:security-operation-permissions@1.0).
-   **Group proliferation**: May need many groups for fine-grained control
-   **No inheritance**: Secrets don't inherit from parent pages

### Risks {#risks}

-   Orphaned secrets if owner is deleted
-   Group changes affect access immediately (no caching)

### Mitigation {#mitigation}

-   Default to admin ownership for orphaned secrets
-   Document group membership implications
-   Provide cleanup commands for orphaned secrets

## Related decisions {#related-decisions}

-   [ADR-006: Audit logging](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-006-audit-logging@1.0) \- Access attempts are logged

## References {#references}

-   [TYPO3 Backend User API](https://docs.typo3.org/m/typo3/reference-coreapi/main/en-us/ApiOverview/BackendUserObject/Index.html)
