Roles and Permissions — Access Control
Controlling who can see and do what usually ends up as a permissions spreadsheet nobody maintains — an incident waiting to happen. In DATTA every capability declares a named permission, permissions are grouped into roles you manage from the interface, and anything not granted is denied by default. You see the access map on a single screen, adjust it in a few clicks, and get an audit trail of every denial.
Concepts
- Permission — a named, specific capability (e.g.
CHAT_SEND,RULES_EDIT,PLATFORM_ADMIN). It is the smallest unit of control. The catalog is central: every permission in the platform lives in it, and creating a permission outside the catalog is a bug — nobody can grant what is not there. - Role — a manageable grouping of permissions. You assign roles to users; the platform checks permissions. That gives you granularity without multiplying management work.
- Sensitive permission — permissions flagged as sensitive (revealing secrets, destructive operations) require a reason recorded in the audit trail, a stricter usage limit, and a double confirmation in the interface.
- Deny by default — if the user lacks the permission, the action is blocked with a friendly message in Portuguese (HTTP 403) and the attempt is recorded in the audit trail.
Built-in profiles (roles)
The platform ships with four profiles that cover most teams:
| Role | For whom | What it covers |
|---|---|---|
ADMIN | Administrators | Every permission in the catalog, including the sensitive ones. |
ADVANCED_USER | Data curators and engineers | Analytical operations and curation: editing the catalog, ontology, glossary, lineage, pipelines, DATTABI, DATTAX, and source discovery. |
ANALISTA | Analysts | Data reading + running notebooks, DATTAX, and DATTABI dashboards. |
READ_ONLY | Consumers | Consuming dashboards, search, chat, and catalog reading. |
The built-in profiles are synchronized into the system database every time the platform starts, idempotently. Your own roles are preserved by that synchronization — nothing you created is overwritten.
Where it is administered
The management screen is , where the catalog appears grouped by category — the same grouping used throughout the interface. To grant access to a person, use : each user gets a base role and can receive additional direct permissions for edge cases. Account details are covered in the users guide.
Permission changes take effect on the user's next sign-in. Server-side blocking, however, is immediate: even if a stale screen still shows a button, the action is denied.
Creating your own role
The four ready-made profiles cover common use; when none fits, Nova role creates one of your own. Give it a name (2 to 40 characters, letters, digits or underscore — it is upper-cased and never changes afterwards, because users and rules start pointing at it), a description saying what it is for, and tick the permissions. The filter searches name, description and category; the counter shows how many are ticked, which is the measure of what that profile grants.
A role with no permissions grants nothing — the screen refuses before saving, rather than creating a profile that looks real and does nothing.
The name INTERNAL_SERVICE, in any casing, is reserved for the credential the platform's services use to talk to each other: the server refuses to create, edit or assign to a user a role with that name, because it would give whoever received it the identity of an internal platform service. If a role with that name already exists, it can be deleted.
Your own roles can be edited and deleted from the card buttons. The ready-made ones cannot: they carry the Built-in badge and offer no action, because the server refuses to change them — they are the base everything else builds on. If you need something close to a ready-made profile, create your own role and tick the permissions you want.
Catalog categories
Every permission belongs to a category. That grouping is what organizes the roles screen:
CHAT GRAPH DOCUMENT RULES PROCESSOS
USERS CONFIG MODELS AUDIT BACKUP
EMBEDDING SEARCH MASKING CATALOG ONTOLOGY
GLOSSARY LINEAGE PROFILING PIPELINE TWIN
JDBC DRIVER DISCOVERY DATTAX DATTABI
MCP PROMPT STATS NOTEBOOK SYSTEM
PLATFORM NEO4J OPENSEARCH VALKEY KAFKA
LLM SIQLCross-cutting permissions
These cover shared capabilities that many screens use, and therefore behave differently from the fine-grained functional permissions:
| Permission | Category | What it unlocks | Fine granularity |
|---|---|---|---|
LLM_INVOKE | LLM | Invoking language inference in the platform (chat, completion, and embedding generation). | Per-model limit/quota is a separate control. |
MCP_INVOKE | MCP | Access to the MCP channel: listing tools, opening a session, and invoking. | Each individual tool requires its own MCP_TOOL_*. |
PRESENCE_VIEW | CHAT | Real-time presence channel (who is online). | — |
PROMPT_VIEW | PROMPT | Viewing prompt templates (system, entity extraction, data extraction, investigation). | — |
PROMPT_EDIT | PROMPT | Editing, creating, and deleting templates and the chat personas — sensitive, because it affects AI behavior across the whole platform. | — |
CAPTAIN_ADMIN | CHAT | Viewing and cancelling other users' Captain sessions, including the journal (goal and steps) through the MCP tool. | Without it, each user only reaches their own. |
PLATFORM_VIEW | PLATFORM | Reading infrastructure resources and metrics in the platform console. | — |
PLATFORM_ADMIN | PLATFORM | Non-destructive infrastructure changes (scale, restart, edit non-sensitive configuration). | — |
PLATFORM_SENSITIVE | PLATFORM | Revealing secrets, isolating or draining a node, force-removing a resource — always requires a reason. | — |
Default distribution per profile
A cross-section of the cross-cutting and most used permissions, and how the built-in profiles distribute them. The complete list is always visible on the roles screen itself — that is the up-to-date source, category by category.
| Permission | ADMIN | ADVANCED_USER | ANALISTA | READ_ONLY |
|---|---|---|---|---|
CHAT_SEND | ✓ | ✓ | ✓ | ✓ |
LLM_INVOKE | ✓ | ✓ | ✓ | ✓ |
MCP_INVOKE | ✓ | ✓ | ✓ | ✓ |
Discovery MCP_TOOL_* (code graph, capabilities, Captain journal) | ✓ | ✓ | ✓ | ✓ |
Read and analysis MCP_TOOL_* (Cypher and JDBC queries, searches, data model, GDS/DATTAX, R, statistics, DATTABI listings, triage report) | ✓ | ✓ | ✓ | ✗ |
Creation MCP_TOOL_* (DATTABI workspaces, dashboards, charts, datasets, models and measures, connections, catalog classification, compose) | ✓ | ✓ | ✗ | ✗ |
DATTABI_R_EXECUTE | ✓ | ✓ | ✓ | ✗ |
PRESENCE_VIEW | ✓ | ✓ | ✓ | ✓ |
PROMPT_VIEW | ✓ | ✓ | ✓ | ✗ |
PROMPT_EDIT | ✓ | ✗ | ✗ | ✗ |
CAPTAIN_USE | ✓ | ✓ | ✓ | ✓ |
CAPTAIN_ADMIN | ✓ | ✗ | ✗ | ✗ |
SEARCH_EXECUTE | ✓ | ✓ | ✓ | ✓ |
SEARCH_INDEX_WRITE | ✓ | ✗ | ✗ | ✗ |
CATALOG_VIEW | ✓ | ✓ | ✓ | ✓ |
CATALOG_EDIT | ✓ | ✓ | ✗ | ✗ |
RULES_VIEW | ✓ | ✓ | ✗ | ✓ |
RULES_EDIT | ✓ | ✓ | ✗ | ✗ |
RULES_DELETE | ✓ | ✓ | ✗ | ✗ |
AUDIT_VIEW | ✓ | ✓ | ✓ | ✓ |
PLATFORM_VIEW | ✓ | ✓ | ✓ | ✗ |
PLATFORM_ADMIN | ✓ | ✗ | ✗ | ✗ |
PLATFORM_SENSITIVE | ✓ | ✗ | ✗ | ✗ |
USERS_MANAGE_ROLES | ✓ | ✗ | ✗ | ✗ |
BACKUP_RESTORE | ✓ | ✗ | ✗ | ✗ |
DRIVER_MANAGE | ✓ | ✗ | ✗ | ✗ |
MASKING_CONFIGURE | ✓ | ✗ | ✗ | ✗ |
MODELS_MANAGE | ✓ | ✗ | ✗ | ✗ |
EMBEDDING_GENERATE | ✓ | ✓ | ✓ | ✓ |
EMBEDDING_GENERATE is the permission to request the vector of a text and to follow the embedding generation in progress. Triggering, verifying and cancelling that generation — a single platform-wide job that keeps the embedding engine busy for hours — requires MODELS_MANAGE (issue #1912); until then, any profile could cancel the generation the administrator had started.
For the same reason, regenerating the vectors of every rule on the platform (the rule excerpt reindex) and opening an embedding model switch require MODELS_MANAGE, or the platform's server-to-server channel (issue #1935). The rule reindex used to require RULES_EDIT, which the advanced profile has; no screen calls it.
A permission to use is not a permission to write
Two routes charged the permission to use the feature and therefore charged nothing: both were reachable by the read-only profile. Fixed on 2026-09-26; they are recorded here because they change the routes' contract.
| Action | Used to require | Now requires | Why |
|---|---|---|---|
| Create, edit and delete a chat persona (System → Agents) | CHAT_SEND | PROMPT_EDIT | A persona's system instruction goes into the prompt of every user who selects it, and the permission it requires was a writable field too — a restricted persona could be cloned with a looser gate. Viewing and listing still use CHAT_SEND. |
MCP tool captain_logs_query, usuario filter | MCP_TOOL_CAPTAIN_LOGS | CAPTAIN_ADMIN to see other users' | The journal carries the e-mail, the goal, and the steps of every session. The per-user filter is now imposed by the server from the caller's credential: without CAPTAIN_ADMIN the query is pinned to the caller, and asking for someone else's is refused. |
Also in this revision: running a persona from its own card now requires the permission that the persona itself declares — the same judgement the chat conversation already applied — and the property synonym written by the chat now records its author and rejects a term with a line break or longer than 60 characters — it enters the instruction of every user of that context.
Layered security
- The interface hides what the user cannot do — nobody clicks a button just to receive an error.
- The platform blocks on the server, regardless of the interface. That is the real barrier: it never depends on the browser.
- The audit trail records every denial with the actor, the required permission, the resource, and the outcome. With the interface hiding the actions, a recorded denial now genuinely signals an anomalous attempt.
Which of the server-side layers enforces a route's permission — the aspect, the path matcher, or a guard in the handler body — is documented in Declared permission and effective gate, in the architecture section. That page also carries the measurement that precedes any tightening: before a route starts denying, we measure which roles would lose access.
Every access denial generates the AUTHZ.PERMISSION_DENIED event, with actor, permission, resource, method, and outcome=denied. The event covers both denial paths: the route-level block — in which the permission comes through as (path-gate) and the resource is the route itself — and the block by the permission declared on the endpoint, in which the required permission appears by name and the resource identifies class and method.
The event is written as a structured log (logger datta.audit.authz, WARN level, prefix AUDIT event=AUTHZ.PERMISSION_DENIED) and carried by the platform's telemetry into the OpenSearch otel-logs-* index, queryable in Trace Analytics and in OpenSearch Dashboards Discover, filtering by AUTHZ.PERMISSION_DENIED. There is no access-audit screen in the platform, and Sistema › Logs does not show this event: that screen reads the system logs, not otel-logs-*. The choice of a structured log is deliberate: the platform does not expose an HTTP audit ingest endpoint today, so any publication through that path would fall into a silent error — the log is what actually guarantees the trail. Creating that ingest endpoint, or migrating the other publishers to the same log channel, is separate work.
Trail of role and permission changes
Every change made in role management — creating, editing, and deleting a custom role, creating a user with an initial role, changing a user's role, and setting a user's direct permissions — generates an audit event after the outcome, stating who did it, on what, what changed, and how it ended:
| Event | When | target | detail |
|---|---|---|---|
AUTHZ.ROLE_CREATED | creating a role | role name | granted permissions: +X,+Y |
AUTHZ.ROLE_UPDATED | editing a role | role name | what was added and removed: +X,-Y |
AUTHZ.ROLE_DELETED | deleting a role | role name | permissions it had: -X,-Y |
AUTHZ.USER_ROLE_ASSIGNED | changing a user's role | user id | previous and new role: ANALISTA->AUDITOR_FISCAL |
AUTHZ.USER_DIRECT_PERMISSIONS_SET | setting direct permissions | user id | +X,-Y over the user's previous direct permissions |
AUTHZ.USER_CREATED | creating a user (Novo Usuário or through the API) | user e-mail | initial role and id of the created account: papel=ANALISTA id=... |
The detail lists permissions in alphabetical order, with + for what was added and - for what was removed. The outcome follows what the screen received:
success— the change was saved;failed— the change was refused (invalid name or permission, built-in role, nonexistent role, role in use, nonexistent user, e-mail or login already in use, invalid registration data) or failed; thereasoncarries the status and the message the screen showed, for exampleHTTP 409: ...;cancelled— the connection was closed before the response. The change may have been saved: check the current state of the role or user.
A typical line:
AUDIT event=AUTHZ.ROLE_UPDATED outcome=success actor=admin@example.gov.br target=AUDITOR_FISCAL detail=+USERS_EDIT,-AUDIT_VIEW reason=The event never carries a password or hash. Line breaks and other control characters that reach a value (a role name typed with a line break, for example) appear escaped as \n, \r, or \uXXXX: each change is always a single line, and a value cannot forge another one.
These events are emitted on the datta.audit.data-access logger, at INFO level — the same channel as the service account events — and not on the datta.audit.authz logger at WARN used by AUTHZ.PERMISSION_DENIED. To see the complete access trail, filter by both loggers.
Where the event lives today. While log telemetry (otel-logs-*) is stopped, the event exists only in the authentication service's log: it does not reach OpenSearch or the audit console, and it is lost when that log is rotated or the service is recreated. When telemetry comes back, the same events will reach otel-logs-* with the datta.audit.data-access logger.
The "before" side of the diff is read right before the write, outside the same transaction: two simultaneous edits of the same target can leave the detail imprecise. This only affects the trail, never the saved change.
Hands-on example — read-only mode in Triage Rules
Access control does not just block: it adapts the interface. The Regras de Triagem (Triage Rules) page (/regras_triagem) is the canonical case.
Previously, the page showed the write buttons to any authenticated user. Anyone without permission clicked, got the error translated into a red notice, and was left not understanding why — a failure of both experience and permission feedback. Today the page reads the logged-in user's permission and adapts: the write actions disappear, a Somente leitura (read-only) badge appears in the header next to the rule count, and rules open in view mode. The user sees and searches all the content, with no buttons that would lead to a denial.
The trigger is the presence of RULES_EDIT (or the ADMIN role) — not the presence of RULES_VIEW. If reading the permission fails for any reason, read-only is the assumed state: deny by default.
What changes on screen
| Element | With RULES_EDIT | Without RULES_EDIT |
|---|---|---|
| Header badge | — | Somente leitura (title="Voce nao tem permissao para alterar regras") |
| + Nova Regra | visible | hidden |
| Co-pilot (generate with AI) | visible | hidden |
| Detail fields (title, block, order, text, contexts) | editable | locked |
| Salvar / Descartar | appear while editing | never appear |
| Ativar/Desativar (enable/disable) and Excluir (delete) | visible | hidden |
| Mover (move, AI rule) | visible | hidden |
| Empty-detail text | "← Selecione uma regra para editar" | "← Selecione uma regra para visualizar" |
The badge only appears for a logged-in user without edit permission. The Manutenção (Maintenance) gear — delete all rules — is separate and requires strict ADMIN; RULES_EDIT is not enough.
AI-generated rules (the "Regras Automáticas" block) stay locked for everyone, including those with RULES_EDIT, until they are moved to a custom block. That lock is independent of the permission-based read-only mode and stacks with it: with either one active, the fields do not accept edits and the Salvar / Descartar bar never shows up.
What the server requires
Every rule mutation — create, update, move, delete, enable/disable, test normative references, and generate with AI, including cancelling the generation — requires RULES_EDIT or ROLE_ADMIN. The block is per route, in the security configuration of the rules service itself; the exact paths are in the API reference. The exception is regenerating the vectors of every rule, which is model maintenance and requires MODELS_MANAGE (issue #1935).
Reading the rules, today, requires only a valid token: any authenticated user lists the rules. RULES_VIEW exists in the catalog and governs the per-role distribution and the governance intent, but it is not yet enforced on reads. Hardening this would require adding a route-level block on the reads as well — open work.
One detail that matters to implementers: in the reactive services, a permission declared on the class is not intercepted on methods that return Mono/Flux — which is why the mutation block is done per route. The user's permission names reach the server through the token and become authorities; ROLE_ADMIN is never blocked. There is also a server-to-server channel (the X-Internal-Token header, which grants ROLE_INTERNAL_SERVICE plus RULES_VIEW and RULES_EDIT) so that batch triage — asynchronous, without a user token — can read the rules without hitting a 401.
Who lands in read-only mode
| Permission | ADMIN | ADVANCED_USER | ANALISTA | READ_ONLY |
|---|---|---|---|---|
RULES_VIEW | ✓ | ✓ | ✗ | ✓ |
RULES_EDIT | ✓ | ✓ | ✗ | ✗ |
In practice: READ_ONLY users (they have RULES_VIEW, not RULES_EDIT) and any custom role without RULES_EDIT. ADMIN and ADVANCED_USER edit normally.
ANALISTA does not have RULES_VIEW in the default distribution, but that does not hide the page: the Regras menu item (the "Processar" section) is not filtered by permission, and reading is open to every valid token. An ANALISTA who opens the page sees it in read-only mode, for lack of RULES_EDIT. The missing RULES_VIEW reflects the governance intent, which today is enforced neither in the menu nor on the server.
Step by step for the user
- Open Regras de Triagem. If you only have view permission, the Somente leitura badge appears next to the rule count.
- Browse, filter by block/status, and search by number, title, or text normally — reading is unrestricted.
- Select a rule: the detail panel opens with the fields locked, without the Save, Delete, or Enable/Disable buttons.
- To gain editing, ask the administrator for the
RULES_EDITpermission (or a role that contains it, such asADVANCED_USER).
Step by step for the administrator
- To grant editing: assign
RULES_EDITto the user — via a role or as a direct permission — in . The change takes effect at the person's next sign-in. - To keep someone view-only: ensure
RULES_VIEWwithoutRULES_EDIT. TheREAD_ONLYprofile already covers it. - The destructive "Apagar todas as regras" (delete all rules) operation — the Maintenance gear — remains exclusive to strict
ADMINand requires a reason of at least 10 characters, recorded in the audit trail, regardless ofRULES_EDIT.
Known limitations
- The permission is read when the page mounts; reducing a permission mid-session does not update the badge in real time. The server, however, already blocks any mutation regardless of the screen state.
- The interface is a visual hint; the barrier is the server. Even if the screen showed the actions by mistake, the mutation would be denied.
How a new permission enters the catalog
- Declare the permission in the central catalog, in the right category, with a pt-BR description and the sensitive flag. If the category does not exist yet, create it in the category list.
- Distribute it to the appropriate built-in profiles — remembering that
ADMINcovers everything automatically. - Annotate the endpoint(s) with
@RequirePermission("NOVA_PERMISSAO"). - Document it on this page when the permission is cross-cutting.
- Update the tests that validate the catalog, if any.
The annotation in practice
On a method:
@GetMapping("/datasets")
@RequirePermission("CATALOG_VIEW")
public Flux<DatasetDto> listDatasets() { ... }Declared on the controller class, covering all of its methods:
// declared on the controller class — applies to all of its methods
@RequirePermission("AUDIT_VIEW")The annotation works at both levels, and the method overrides the class — it does not stack: each method has exactly one requirement. Permissions ending in _ADMIN/:ADMIN or _VIEW/:VIEW are automatically inherited by ROLE_ADMIN, which avoids listing each one in the role. Equivalent alternative: @PreAuthorize("hasAuthority('X')").
Where the permission is enforced: aspect or filter
The annotation declares what the endpoint requires; who enforces it depends on the method's return type, and the difference has already been expensive:
| Method return type | WebFlux service (every service on the platform) |
|---|---|
Mono/Flux | The permission aspect enforces it, as long as the module registers the bean. |
ResponseEntity, List, Map, simple type (blocking) | The aspect does not enforce it: the authority lives in the reactive context and the thread-local it reads is empty. The annotation is documentation — the real barrier is the path matcher in the module's security configuration — hasAnyAuthority(...) per verb and path. |
Rules that follow:
- A blocking annotated endpoint without a matcher is an annotation that lies: any authenticated user gets through. That is how 47 governance reads (lineage, ownership, knowledge, asset card, ontology, twins) stayed open to users without
CATALOG_VIEW/ONTOLOGY_VIEW/TWIN_VIEWuntil 2026-09-15. - When the gate lives in the filter, the annotation says so —
@RequirePermission(value = "X", enforcedBy = RequirePermission.Enforcement.FILTER)— and the codegraph checks that a matcher covers the path (permissao_declara_filtro_sem_matcher, gate). DeclaringFILTERwithout a matcher fails. - A new annotated endpoint that nothing enforces fails
make codegraph-check(permissao_sem_gate_efetivo_novo, a ratchet againstscripts/codegraph-permissao-sem-gate-legado.txt). The legacy cases named there are listed in the follow-up issue and only leave the list with a gate. - The matcher does not inherit the aspect's
ROLE_ADMINshortcut: list it explicitly, or a JWT issued before the last update of the authentication service (role only, no expanded list) gets 403 on the administrator's own screen. - Reads follow the
VIEW < EDIT < ADMINhierarchy in the matcher (whoever edits can read), as writes already do (EDITacceptsADMIN). It is also what lets the internal service token through, which grants the module's edit authority.
Where the catalog is stored
The catalog is materialized in the datta system database every time the platform starts, idempotently:
(:Role {name, description, builtIn, createdAt, updatedAt})
-[:TEM_PERMISSAO]-> (:Permissao {name})
(:Usuario)-[:TEM_ROLE]->(:Role)
(:Usuario)-[:TEM_PERMISSAO_DIRETA]->(:Permissao) // grants individuaisThe source of truth is the code; the graph feeds the roles screen, stores your own roles and the direct grants, and is also where login and session renewal take the token permissions from. That is why the sync of the ready-made profiles is differential: a permission removed from the catalog leaves the role on the next boot (and the token on the next login or renewal), an added one comes in, and the others stay as they are.
The synchronization runs sequentially and is preceded by a hygiene routine: it deduplicates :Permissao nodes by name, removes nodes with no edges at all (permissions that left the catalog), and creates the uniqueness constraints on Permissao.name and Role.name.
Troubleshooting — repeated entries or legacy permissions on the roles screen. Just restart the authentication component: the hygiene runs at startup. Without redeploying, you can run the
scripts/neo4j-permissoes-dedup.cypherscript — the instructions are in the file's own header. History: until 2026-06-10 the synchronization was concurrent and had no uniqueness constraint, and simultaneous creation produced up to 4 copies per name; the SPAT→DATTA rebrand left 24 orphaned entries.
Current coverage (2026-05-09)
An architectural sweep on 2026-05-09 ensured that 209 out of 209 REST controllers have their permission declared, on the class or on the method. The 10 endpoints intentionally without access control are:
| Endpoint | Justification |
|---|---|
| Login | public by definition |
| Installer download | public distribution of the installer |
| Health checks (4 files) | availability probes for the platform |
| Error handlers (4 files) | expose no endpoints of their own |
The two permission interceptors — one for the reactive services, one for the traditional ones — support:
- Class-level annotation, covering all methods without repetition.
- Method-level override, when a method needs a higher permission than the class. Example: the search area requires
SEARCH_EXECUTEfor the whole class, but deleting documents by query requiresSEARCH_INDEX_DELETE, and the generic writes to the search index (documents, excerpts, vectors, rules) requireSEARCH_INDEX_WRITE— ADMIN only; the read-only profile searches but neither writes nor deletes. Deleting by query accepts only the exact name of a context index: a pattern, a list or a platform-internal index is refused even for ADMIN (issue #1912). In search, a pattern (datta_*) never reaches the platform's system indices — history, prompts, telemetry, logs, glossary, catalog — and asking for one of them by its exact name is refused (issue #1935). ROLE_ADMINinheritance of permissions ending in_ADMIN/:ADMINor_VIEW/:VIEW.
Anti-patterns rejected in review
- An endpoint without a declared permission.
- A permission declared with a name that does not exist in the catalog — impossible to grant, so it always denies.
- A role check hardcoded in the code (
hasRole('ADMIN')) instead of the specific permission, losing granularity. - A permission granted only in the interface (hiding the button) without a server-side block.
- A sensitive permission without an audit event or without a usage limit.
See also: Users · API overview · API reference