BeryX — Security and compliance
Public document. It is the standard security answer for RFPs and the source of the "Security and compliance" section of the site's
/datavault/rfppage. Each section says what we guarantee, where it is in the code and which test proves it. We claim nothing that is not here.
Mechanical read-only access
BeryX reads metadata, aggregates and limited samples — it never writes, moves or changes data in the customer's sources. It is not just a convention, nor just permissions asked of the customer: the guarantee is enforced in the code.
- HTTP connectors (Jira, Confluence, Databricks, SharePoint, Google Drive, …) only reach the
network through the
ReadOnlyHttpClient(app/infra/connectors/http_guard.py): GET/HEAD/OPTIONS pass; POST/PUT/PATCH/DELETE are refused before touching the network. - No connector can expose a write method: the
enforce_read_onlydecorator refuses, at class definition, any method with a write name, and a test goes through every registered connector. - PostgreSQL: profiling sessions open with
default_transaction_read_only=onandstatement_timeout— a write, even one hidden in a function or view, fails on the server. - Snowflake (engineering sweep):
assert_catalog_statementrefuses anything that is not a singleSHOW/SELECT/DESCRIBEbefore it reaches the driver. - We always recommend a service user with read-only permissions — the steps for each source are in the connectors guide.
Tests: backend/tests/test_http_guard.py, backend/tests/test_connectors_registry.py.
Isolation between customers (multi-tenant)
Each customer is a workspace, and the workspace is the data boundary. An authenticated request
always resolves the workspace of the session or of the API token (get_current_workspace,
app/api/deps.py), and each route only reads and changes resources of that workspace. The BeryxTech.
team only reads another workspace with support access that an administrator of that customer has
approved. That access has a time limit, is read-only and is recorded in the audit log.
Isolation proved by a test over every route. The guarantee does not depend on someone remembering to test each new endpoint. The test goes through the API's registered routes, with two test customers, A and B, seeded with one resource of each kind:
- Between customers. Each route with an id in the path receives A's ids and is called by B's administrator, the most permissive role, and by B's API token. It can never respond 2xx or 5xx. The refusal must be the same as for an id that does not exist, so that B does not even learn whether A's id exists. Routes with the id in the body or in the query go through the same test. In listings, aggregates and exports, no response to B may contain an id or email of A.
- Positive control. The same request made by A reaches its resources. Without this, B's 404 would prove nothing. The test prints the coverage on every CI run and fails below 90% of the routes with a customer id.
- No session. Every route responds 401, except an explicit, commented list of public routes: health, login, SSO, signed webhooks and public links by token. That list must match the access decision table.
- Roles. The read-only role gets 403 on every change. The only exceptions are account self-service, the help request, the assistant, feedback and the bulk trust decision, which is a read. The API token only writes in the intended operations.
- New routes. A new route with a parameter the test does not know how to fill makes the test fail. It only passes again when the route enters the matrix or a justified list.
- Cross-workspace routes by design. The BeryxTech team's console, the organisation and federated search are proved separately, each at its own boundary. For example, support access without approval gets 403 on every read.
- The test also bites. An example router without a workspace filter is caught by the same sweep.
Tests: backend/tests/test_tenant_isolation_matrix.py. Per-domain permissions within a workspace
are in backend/tests/test_access_matrix.py.
Samples: what leaves the source and what stays
What a scan reads from your sources, where it is processed and what is stored:
- Profile on SQL sources: aggregates. On SQL databases and warehouses, the profile (counts,
nulls, distinct values, minimums and maximums) is calculated by SQL on the source itself. Only
the numbers come back, plus a sample of up to 1,000 rows of the profiled columns
(
VALUE_SAMPLE_ROWS,app/infra/connectors/sql.py), read in memory on the worker to take up to 20 example values per column, which feed the semantic types and PII detection; only those values are stored. Up to the previous version it was 20 values per column, in one query per column. On sources without SQL (files, SaaS APIs, streaming), the connector reads the records through the official API and the profile is calculated on the worker; the connectors guide says what each one reads. - Row rules and duplicates: a sample of up to 5,000 rows per table. On each scan, the rules
that need rows and duplicate detection read at most 5,000 rows per table
(
ROW_FETCH_LIMIT,app/domain/scans/service.py), chosen by the source's own sampling (TABLESAMPLE/SAMPLE), by the primary key hash or, with neither, by the first rows (method). It is a ceiling, which also applies to the profile sample: the DBA can lower it per source, down to a minimum of 100 rows (max_sample_rows, "Good neighbour to the DBA"). The rows are processed in memory on the worker, in the EU (Google Cloud europe-west1); from that read, only the limited examples described below are stored. - Larger reads only when you ask for them. Label quality (on the datasets where you configure it), data-diff and matching between datasets read up to 50,000 rows per table, also in memory on the worker.
- What is stored, in the EU: profiles, aggregates, rule results and scores, and limited examples — for example, up to 20 values per column in the profile, up to 5 rows per duplicate group and up to 5 truncated values per failed rule. The table is never copied to BeryX.
- With AI on, the examples sent to the model follow the workspace's AI policy. By default they go to Anthropic, which processes them in the US under standard contractual clauses. To keep them in the EU, turn AI off or use Claude in your cloud account, on Bedrock or on Vertex (Enterprise plan) — see "Your models" and "Anonymisation before the LLM", below.
- Running in your Databricks (ADR 0005, step B): it does not run in scans yet. The
beryx-profilerpackage, which reads the full table in your account and returns only metrics, and the Jobs API client already exist in the code, but no scan submits it yet. Until then, on Databricks too, row rules and duplicates use the sample above. - Today we have no mode in which no row leaves your network (on-prem or air-gapped). A runner inside your perimeter (step C of the same ADR) depends on real demand.
Tests: backend/tests/test_seguranca_amostras.py (the numbers and the Databricks status above are
compared with the code).
Proposed enrichment: what leaves for each provider
Proposed enrichment asks public reference services, or a Nominatim instance of the customer's own, for the value missing in a column. Each provider receives only the field to enrich, one value at a time, and never the full sample or the other columns of the row:
| Provider (key) | Host | What it receives | What it returns |
|---|---|---|---|
GeoAPI.pt (geoapi_pt) |
json.geoapi.pt |
The CP7 postcode (full Portuguese postcode) | Locality, municipality and district |
VIES, from the European Commission (vies) |
ec.europa.eu |
Only the NIF (Portuguese tax number) of a legal person (first digit 5, 6 or 9). The NIF of a natural person does not leave | Registered name and address |
Public Nominatim, from the OpenStreetMap Foundation (nominatim) |
nominatim.openstreetmap.org |
Only the Spanish postcode | Locality and autonomous community |
The customer's Nominatim instance (nominatim_self_hosted) |
The one in the URL the administrator gives | The address, for geocoding. It is the only provider that receives personal data | Latitude and longitude |
Countries and currencies are compared with the ISO lists stored in BeryX, and nothing leaves.
- Opt-in per workspace and fail-closed. By default, no provider receives anything. An
administrator allows each one under "Rules → Enrichment providers"
(
PUT /api/v1/workspace/enrichment), and the authorisation records who consented and when. Without authorisation, the proposal is refused before the source sample is read. This authorisation is separate from that of theaccuracyrule: allowing VIES in one does not allow it in the other. - Addresses only to the customer's instance. Geocoding uses only the customer's own Nominatim instance. The usage policy of the public Nominatim asks that no personal data be sent to it. So the public Nominatim receives only the Spanish postcode and is allowed without the personal data confirmation. The same policy limits use to 1 request per second. The OpenStreetMap Foundation stores request data in the United Kingdom and the Netherlands (OSMF privacy policy).
- One consent per destination. To allow the instance, the administrator gives the URL
(
nominatim_url) and confirms that the addresses may be personal data (confirm_personal_data). Changing the URL is a new destination and asks for the confirmation again, which records who gave it and when. Withdrawing the authorisation deletes the URL. Without a configured and allowed instance, no address leaves. - The instance URL is validated when saved. It must be https (http only for hosts in
DATAVAULT_CONNECTOR_ALLOWED_HOSTS), with no user or password, no query or fragment, and up to 500 characters. Hosts underopenstreetmap.orgare refused, because they are the public service. The anti-SSRF guard (SsrfGuard, inbackend/app/infra/connectors/http_guard.py) refuses hosts that resolve to internal networks (loopback, private networks, cloud metadata), unless the operator allows them inDATAVAULT_CONNECTOR_ALLOWED_HOSTS. The same code normalises the URL when saving and when querying (backend/app/infra/enrichment/providers.py), and every lookup goes through the guard again, with DNS pinning. - The request to the instance. BeryX calls
<url>/searchwithq(the address),format=jsonv2andlimit=1, by GET only and with a User-Agent that identifies it. The request carries no credential or key: an instance behind authentication is not supported. - Outside the subprocessor list. The three public services are not on the public subprocessor list: they only receive values from a workspace after an administrator of that workspace allows them, and each one handles the requests under its own terms. The Nominatim instance is the customer's own, and the customer chooses the destination.
- Audit. Every change to the allowed providers goes into the audit log
(
enrichment_providers_updated), with the list before and after, whether personal data was confirmed and, when it changes, the host of the Nominatim instance. - How it leaves. GET requests only, through the connectors' read-only HTTP client, with the anti-SSRF guard and DNS pinning. The public services have a minimum interval between requests in each API process (0.5 s for VIES and 1 s for GeoAPI.pt and the public Nominatim) and each proposal has a lookup cap. Answers are kept in an in-memory cache, per workspace and with the key hashed (SHA-256). Values never go to the logs.
- What is stored. The suggestion stores the proposed values with their provenance (source, lookup date and confidence). For geocoding, the suggestion stores only the provenance and the coverage: neither the address nor the coordinates, which would locate it. The proposed coordinates only appear in the response to the request. The suggestion's SQL carries the postcodes and company NIFs the source confirmed, with the reference value (for VIES, the company's registered name and address). For geocoding, the SQL carries no address at all.
- What the source did not confirm is not kept. A natural person's NIF, a postcode the source does not know or free text in the column is not kept in the suggestion. In the response to the request, it appears masked, with only the last 3 characters. The pull request carries the coverage and the source, not the list of values.
BeryX does not write the proposed value to the source: the customer applies it by pull request or SQL, after approving the suggestion (what we do not do).
Tests: backend/tests/test_enrichment.py.
Encrypted credentials and key rotation
- Source credentials and integration tokens are encrypted with Fernet
(
app/domain/catalog/crypto.py) before being stored. They are never returned by the API nor written to logs; vendor error messages go through a filter that removes secrets. - Key rotation:
DATAVAULT_ENCRYPTION_KEYSaccepts several keys (MultiFernet): the first encrypts, all decrypt.backend/scripts/rotate_fernet.pyre-encrypts every secret with the new key in idempotent batches; tokens that no key opens are reported only byid, without ever being decrypted for logs (app/domain/catalog/key_rotation.py).
Tests: backend/tests/test_fernet_rotation.py.
Anti-SSRF protection with DNS pinning
Source URLs come from customer configuration. So that they cannot point inside our infrastructure
(cloud metadata, localhost, private networks):
SsrfGuard(app/infra/connectors/http_guard.py) only acceptshttpsand refuses any host that resolves to an internal address. It is fail-closed.- DNS pinning: the host is resolved only once, every IP returned is validated and the
connection is made to the pinned IP, with the original hostname in
Hostand in SNI (the TLS certificate is still validated against the name). This closes the DNS rebinding window. - Each redirect hop repeats resolution, validation and pinning. On a hop to another origin
(scheme, host or port), only the neutral headers go through, such as
AcceptandUser-Agent(NEUTRAL_HEADERS). TheAuthorizationheader, cookies, query params and any custom credential header, such as anX-API-Keyor theX-Tableau-Auth, are dropped. - Response cap and total deadline (cards #571 and #605). The HTTP connectors' transport
(
pinned_httpx_transport) reads the body as a stream and stops at the connector's cap: 20 MB for REST and for SharePoint and Google Drive documents, 30 MB for OData and Dynamics 365 and 32 MB when the caller sets none. AContent-Lengthabove the cap is refused before the body. It only asks forgzipanddeflate, decompresses them with the output limited to the same cap and refusesbrandzstd. The timeout counts for the whole request, from the connection to the last byte, not per read. - It also applies to outbound integrations that receive customer URLs (external catalogue, OpenLineage, self-hosted Git): validation on save and on each use.
- Data sources (databases, warehouses, object storage). Every host and URL in the config goes
back through the guard (
netguard.check_host) before the connection is opened, not only when the connection is saved (BaseConnector.assert_destination_allowed, card #339). Postgres connects only to the validated IPs (hostaddr), in the resolver's order, and MySQL opens the socket to them, with the certificate verified against the name. pymongo resolves each name through the guard (netguard.guarded_getaddrinfo, card #564) and connects to the addresses validated in that same resolution, the driver's monitoring included, and it never opens a local Unix socket, not even for a member the replica set announces (card #602). The other dialects revalidate and the driver resolves the name again. In production, a name that does not resolve is refused when the connection is created and on each revalidation, and the drivers with a pinned IP also refuse a local Unix socket and a list of hosts. The per-dialect table is in CONECTORES.md. - Webhooks and tickets. Alert channel webhooks (Slack, Teams and generic webhook), scheduled
reports on those channels and tickets (Jira, Linear, ServiceNow, Azure DevOps, GitHub and
GitLab, card #376) all go out through
app/infra/network/outbound.py. The URL is validated on save, in the app and in the IaC API, and again on each send, with the pinned IP. On the generic webhook, each retry attempt validates again. Redirects are not followed: a3xxcounts as a failure. A refused destination is not retried and shows in "Last delivery" as "destination refused (SSRF)". - A webhook URL is a secret: whoever has it can post to the channel. It does not appear in the
"Test channel" response, in
last_delivery_erroror in the logs, which keep only the HTTP code or the error type. Thehttpxlogger, which at INFO would write the URL of each request, is set to WARNING. Excerpts of ticket provider responses go out without the credentials. DATAVAULT_CONNECTOR_ALLOWED_HOSTSalso exempts webhooks and tickets (for example, a receiver on the customer's network in a dedicated instance). On channels,httpsis still mandatory.
Tests: backend/tests/test_http_guard_pinning.py, backend/tests/test_dynamics_ssrf.py,
backend/tests/test_outbound_ssrf.py, backend/tests/test_connectors_sql.py,
backend/tests/test_security_gates.py, backend/tests/test_dns_rebinding_guard.py,
backend/tests/test_http_guard_response_limits.py.
Perimeter: WAF, rate limiting and DDoS
Status: implemented as code and covered by automated tests, but not yet applied in production.
The global load balancer (deploy/lb_apply.sh) and Cloud Armor on top of it
(deploy/armor_apply.sh) are waiting for an operation by the team. Until then:
- the API and the app are served directly by Cloud Run, and each service's
*.run.appaddress also responds; - abuse containment is the API's own rate limit, in memory and per instance, plus the Cloud Run instance ceiling;
- there is no WAF in front of the service.
Activation is phased. First the load balancer goes in. Then Cloud Armor spends a week in observation mode, in which nothing is blocked and false positives are measured. Only then does it start blocking. This section will only say "active" after blocking is on in production.
With the edge applied, the perimeter looks like this:
- Load balancer: all public traffic (site, app, API and MCP) enters through a global Google Application Load Balancer, with TLS ≥ 1.2. The services then refuse requests that do not come from it. The LB only forwards well-formed HTTP(S) requests, so L3/L4 volumetric attacks stay on Google's network. Cloud Armor sits in front of the LB's backend services.
- WAF with the OWASP CRS 3.3 rules managed by Google: SQLi, XSS, LFI, RFI, RCE, protocol
attacks and scanner detection, at sensitivity 1, the most conservative.
- Endpoints that receive legitimate SQL, YAML or XML are left outside the WAF: rule import, connections, dbt manifest, OpenLineage, SAML, signed webhooks, among others. They are authenticated and validated in the application.
- The WAF inspects the first 8 KB of the body of each request.
- Per-IP rate limiting at the edge, with central counters that do not depend on the number of
instances:
- 600 requests/min per IP on the API;
- 20 requests/min per IP on login, sign-up, password recovery, email verification and MFA code, with a 10 min block for anyone who goes over. Distributed brute force is contained before it reaches the application.
- Adaptive Protection: layer 7 DDoS detection by machine learning, with alerts.
- Scanners and methods:
TRACE,CONNECTand requests to/.env,/.gitand/wp-*get - Exemptions: the rate limit is only bypassed with a header whose value is in Secret Manager (the production validation and the uptime checks), never by IP. The header does not bypass the WAF.
- Monitoring: blocks are counted in a metric without sampling. Above 200 in 5 min, the team gets an alert.
- Defence in depth: the API keeps its own rate limit per IP, per token and per expensive
endpoint. MCP (
mcp.<domínio>) goes through the same LB without Cloud Armor, because agents come from the provider's shared IPs. It is authenticated by token and subject to the API's rate limit.
Tests: backend/tests/test_edge_armor.py (rules, exemptions, idempotency, secret never printed).
After blocking is on in production, deploy/check_edge.sh proves three cases on each deploy:
SQLi in a query param → 403, scanner path → 403, a burst at login from one IP → 429 in under
10 s. While Cloud Armor is not blocking, these three cases appear in the check only as
information, neither passing nor failing.
API HTTP headers and body cap
The API answers on its own URL, without going through the app's nginx, and that URL is what the SDK,
the MCP and a scanner test. So it sends the security headers itself
(backend/app/api/security_headers.py), on every response, errors and 429s included:
Strict-Transport-Security(one year, with subdomains),X-Content-Type-Options: nosniff,Referrer-Policy: no-referrer,X-Frame-Options: DENYand aPermissions-Policythat turns off camera, microphone and geolocation;Cross-Origin-Resource-Policy: same-site, except on the/api/v1/public/routes, which getcross-originbecause a badge is embedded as an image in third-party pages;- on JSON responses,
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'; - on a request with
Authorization,Cache-Control: no-store.
A header the route has already set is left as it is: the public badge keeps its own Cache-Control
and CSP, and the SSE streams their no-cache.
A request body is capped at 4 MB (api_max_body_bytes, in backend/app/api/body_limit.py). A
Content-Length above it gets a 413 without the API reading the body. A body without
Content-Length is counted as it arrives and reading stops at the cap. The 413 is sent before
authentication and carries detail and max_bytes. Routes that already had their own cap keep it
(ROUTE_BODY_LIMITS): dbt manifest, dbt results, OpenLineage, rule import and the RAG gate. Record
validation (POST /datasets/{id}/validate) has 2 MB, set by path pattern
(ROUTE_BODY_LIMIT_PATTERNS).
The app sends its own through nginx, with a CSP of its own for the SPA
(frontend/nginx/security-headers.conf). The production validation (deploy/validate_prod.sh §17)
checks HSTS and nosniff on the API's /health, the app's six headers and, on the site, HSTS,
nosniff and the CSP. The site's CSP stays in Report-Only until a week goes by without violations:
the browser blocks nothing and sends each violation to /api/csp-report, which reads at most 16 KB
per report and logs up to 20 reports per IP every 10 minutes. Tests:
backend/tests/test_http_hardening.py.
GDPR: self-service export and deletion
Without a request to support:
- Export the workspace data —
GET /api/v1/workspace/export(administrators). - Delete the workspace —
DELETE /api/v1/workspace: cascading deletion of connections, datasets, profiles, scans, reports, integrations and credentials (delete_workspace_cascade,app/domain/catalog/erasure.py). - Delete your own account —
DELETE /api/v1/auth/me: anonymises authorship in the records that must survive (e.g. audit trail) and deletes the rest (erase_user). - Deletion certificate — deleting the workspace issues a signed certificate (Ed25519, with the
issue date inside what is signed). It counts the rows in each data category before and after
the cascade, in the same transaction, and states until when the backups hold the deleted data.
It is kept in the platform trail (
staff_events, no email addresses) and emailed to the admins as JSON and PDF (app/domain/compliance/deletion_certificate.py). Verify it withbackend/scripts/verify_deletion_certificate.py. - Retention per workspace —
GET/PUT /api/v1/workspace/retention(administrators): limits for the profile history (never above the plan window), AI reports and row samples. The worker's daily purge applies them and writesretention_purgedto the Audit log, with the cut-offs and the counts (app/domain/billing/retention_policy.py,app/domain/billing/retention.py).
The privacy policy and the terms are published on the site (/privacidade, /termos).
Product analytics: opt-in and no personal data
The app only measures which pages and features are used when a workspace admin turns sharing on.
It ships switched off (analytics_enabled), and the admin answers once after sign-up. The choice
can be changed in Team & Access, which shows the exact list of what is collected (GET/PUT /api/v1/workspace/analytics). Each change goes to the Audit log (analytics_consent_changed).
- What is stored — the workspace, the plan, the event name, the page pattern
(
/datasets/:datasetId, never the URL with ids) and the time (product_events). The user, the email, the IP, the user agent and typed text are not stored. Events, routes and properties come from closed lists (app/domain/analytics/events.py). Anything else is dropped. - Intake —
POST /api/v1/events/product, in batches of up to 50 events, with its own request limit (product_events, 60 per minute). It always answers 204: with sharing off nothing is stored. - Retention — 90 days, through the daily purge (
app/domain/billing/retention.py). Switching sharing off deletes the workspace's events at once (app/domain/analytics/service.py). The events are in the workspace export and in the cascading deletion. - What BeryxTech sees — in the Console, only percentages per plan and counts per page over the last
30 days. A cell with fewer than 5 workspaces shows no numbers, and BeryxTech's own workspaces are left
out (
app/domain/analytics/adoption.py).
Labelling of AI content (Article 50 of the AI Act)
All content produced with a language model — reports, suggestions, diagnoses, assistant answers —
carries a footer stating that it was generated with AI support, which model and which provider
actually responded, and that it is subject to human review, under Article 50 of
Regulation (EU) 2024/1689 (ai_generated_footer, app/domain/insights/templates.py). Offline
mode (no AI) carries its own footer that says so.
Machine-readable marking (Article 50(2)). Generated text also carries three fields, computed
in a single place (app/domain/insights/ai_marking.py): ai_generated, ai_model and
generated_at. They are output:
- in the API, in reports and RCA diagnoses, in suggestions and in the dataset README (draft and published version);
- in the assistant's
doneevent, which also names the model that responded; - in the HTML export and the public share, as
<meta name="ai-generated">,ai-modelandai-generated-at, with the visible label "AI-generated (model, date)"; - in the PDF metadata (
aigenerated,aimodel,aigeneratedat), in the front matter of the exportedREADME.mdand in the Slack and Teams cards of scheduled reports (visible label).
Offline-mode text goes out with ai_generated=false. The model name is cleaned before it goes out:
an ARN or a project path from the customer's cloud account is reduced to the model family or to
undisclosed. In the assistant, the panel says from the first interaction that you are talking
to an AI system (Article 50(1)). Tests: backend/tests/test_ai_marking.py and
frontend/src/pages/Assistente.dom.test.tsx.
For customers with high-risk AI systems, BeryX also exports the Article 10 evidence
dossier (governance of training, validation and test data), with the gaps made explicit
(app/domain/compliance/ai_act.py, test backend/tests/test_ai_act_art10.py).
AI actions
The chat assistant can propose actions in the workspace: create a rule, log, assign or snooze an
issue, request a review of the PII classification, open a remediation PR, among others.
Proposing never executes. The user sees a card with what is going to happen and only approval
executes it, on an endpoint that re-checks the card's signature (HMAC, valid for 24 h), the role,
the plan and the policy, and records the audit event assistant_action_executed.
Each workspace has an AI actions policy (Team → AI actions, administrators only). For each action type, it decides:
- whether it is on. When off, it is removed from the assistant's tools and execution is refused;
- who proposes and who approves (member or administrator). The approver is never below the role the API requires for the same operation. Whoever can propose but not approve sends a request, which an administrator approves or declines. Requests are valid for 7 days;
- auto-approval, only for the types marked as safe (snoozing an issue, assigning an issue to
oneself) and off by default. Even without a click, the execution is kept in the trail, marked
auto_approved. Auto-approval also respects per-domain roles (ADR 0011): it only runs when the requester can change the issue's dataset, the same check as "Approve". In a restricted domain where they are a viewer, or that they cannot see, the card waits for "Approve", which refuses it; - the daily limit in the workspace (UTC day). At the limit, the assistant explains it in the chat and approval responds 429 with the reason;
- the allowed destinations (for example, the git repositories), the required fields and the template for the body.
Viewers never receive proposals and an API token never approves. Without changes, the API
permissions apply, as before the policy existed. Each change is recorded in the audit log
(assistant_policy_changed, with the before and after), as is each request
(assistant_action_requested) and each refusal (assistant_action_request_rejected). The same
screen decides what the steward's daily round can propose and apply on its own.
Implementation: app/domain/assistant/policy.py. Tests:
backend/tests/test_assistant_action_policy.py.
Public endpoint of the Slack app
BeryX's Slack app (card #193) is created by the customer in their own Slack workspace, from a
manifest with three bot scopes: chat:write, app_mentions:read and im:history. It receives
mentions and direct messages to the bot at POST /api/v1/webhooks/slack/events/{integration_id},
a public endpoint with no session and no API token. Authentication is Slack's signature:
- Signature over the raw body. The endpoint checks
X-Slack-Signature(v0=+ HMAC-SHA256 with the integration's signing secret overv0:{ts}:{raw_body}) in constant time, before parsing the JSON (app/infra/collaboration/slack.py). An invalid signature gets 401, and theurl_verificationchallenge is only answered after this check. - 5-minute replay window. An
X-Slack-Request-Timestampmore than 5 minutes away from the server clock gets 401, even with the right signature. - Team, channel and dedupe. The event's
team_idmust be the one Slack returned when the integration was saved. Only mentions and messages in the DM with the bot get through, never from bots, and only in the channels an administrator has allowed. A repeatedevent_idis not processed twice. - Suspended tenant. With the workspace suspended by BeryxTech, the endpoint neither stores nor queues questions, the worker closes the ones already queued without calling the model or Slack, and posts waiting to go out are not sent. It is the same rule as for sessions and API tokens.
- Immediate ack. The endpoint stores the question encrypted with Fernet, publishes it to a queue and responds 200. The assistant runs on the worker, read-only and with the permissions of whoever linked the Slack account to the BeryX account. The question is deleted when the request closes.
- Limited output. The Slack client only calls
chat.postMessage,chat.postEphemeralandauth.test, and refuses any other method before reaching the network. Proactive posts carry only ids, counts, catalogue names, severity and links, through a closed list of keys. In the bot's answers, the rule against copying values or samples is an instruction to the model. The mechanical filter is the AI output guardrail, which masks emails, phone numbers, NIF/IBAN, cards, IPs and secrets, plus mrkdwn escaping, so no answer can notify a whole channel. Other column values are kept out only by the instruction.
Implementation: app/domain/collaboration/inbound.py. Installation guide:
SLACK_APP.md. Tests: backend/tests/test_slack_app.py.
Your models: your own key, Amazon Bedrock and Vertex AI
- Your own Anthropic key per workspace (encrypted like any credential).
- AI policy per workspace: "AI off" (only offline heuristics and templates) or a restriction of models per task.
- Claude in the customer's cloud account (Enterprise plan): Amazon Bedrock,
eu-*regions only, viasts:AssumeRolewith a per-workspaceExternalId— Bedrock onboarding; or Google Cloud Vertex AI,europe-*regions only, by impersonating a service account of the customer, with no JSON keys — Vertex onboarding. The data and the token bill stay in the customer's account; BeryxTech keeps no secrets for that route. - If the customer's route fails, AI degrades to the offline modes — it never falls back to BeryxTech's account against the customer's choice.
Anonymisation before the LLM
- By default, only Anthropic receives customer data. An alternative provider never receives raw samples: only metadata (column names, types, counts) or, if the workspace explicitly turns it on, already anonymised samples.
- Samples sent to a model are limited in size and can be anonymised before they leave: the engine
(Microsoft Presidio, with Portuguese recognisers for NIF (tax number), NISS (social security
number), IBAN/NIB, phone number and Cartão de Cidadão (identity card)) replaces the values with
placeholders that are reversible only within the session (
app/domain/llm/anonymize.py). - Routing refuses any non-Anthropic step that does not receive the sample already anonymised
(
app/domain/llm/routing.py).
Tests: backend/tests/test_llm_anonymize.py.
Sign-in attempt limits
Password sign-in has three limits, in backend/app/api/ratelimit.py:
- Per IP and email: 5 attempts per minute.
- Per IP: 30 attempts in 5 minutes, added up across sign-in, sign-up and password recovery requests. It stops password spraying, where one weak password is tried against many emails from the same IP.
- Per account: 20 attempts in 15 minutes, from any IP. It stops brute force spread across many IPs against the same account. The key is the sha256 of the email and applies the same way to emails without an account. Password recovery requests have a separate per-account limit, with the same numbers.
All three are checked before the attempt counts, so a 429 from one does not use up the others. A
sign-in with the right password uses up none of them. Over the limit, the response is 429 with
Retry-After and the same message whether or not the account exists, and it leaves before the API
looks the account up. When the per-account limit trips, the audit log of the account's workspace
gets login_throttled, with the email hash and the IP, never the password, once per account in each
window.
Anyone who knows an account's email can block its password sign-in for 15 minutes. SSO and password recovery requests keep working during that time.
Through the web app, the per-IP limit counts the browser's IP, which the web server vouches for to the API with a shared key. In production, the API does not start without that key. Outside production and without it, the API sees the same IP on every request from the app. So that some users' failures do not block sign-in for the others, a request that presents itself as coming from the web server then stays out of the per-IP limit. The per-IP-and-email and per-account limits still apply. In production, the key has been active since 11/10/2026.
In the API's per-IP limits, an IPv6 address counts by its /64 block and an IPv4 address by the full address. Whoever holds an IPv6 block picks the end of the address, and switching address inside the block does not open a new limit.
The counters live in memory in each API instance: with 3 instances, each limit can be worth up to three times as much. Central containment by real IP is Cloud Armor's (see «Perimeter»), not yet applied in production.
Tests: backend/tests/test_security_gates.py and backend/tests/test_ipv6_rate_limit.py.
Sessions
Every sign-in is recorded with the device, the truncated IP (/24 for IPv4, /48 for IPv6), the
creation date and the last use; the token is never stored (backend/app/domain/auth/sessions.py).
In “Account security → Active sessions”, each person sees where they are signed in and ends one
session or all the others. “Sign out” also ends the session on the server.
- Inactivity. A session with no requests for 12 hours ends. The workspace administrator can require less, down to 15 minutes, in “Team → Sessions”. The change applies to sessions that are already open. A request made with no interaction for more than a minute does not count as activity: this covers pages with live updates and the notification bell's connection, which reopens on its own every 4 minutes.
- Forced sign-out. The administrator ends every session of a member in “Team”. The person can sign in again with their credentials.
- Password and MFA. Resetting the password, turning MFA on or off and regenerating the recovery codes end every session of the account.
- Every revocation, forced sign-out, password change and timeout change goes to the audit log.
API tokens
A dvk_* token is stored only as a SHA-256 hash, and its value is shown once, when the token is
created or rotated (backend/app/domain/access/token_lifecycle.py).
- Expiry. Every new token expires: 90 days by default, or between 1 and 365 days, as the
administrator chooses. An expired token gets 401 on every route, with the same message as an
invalid token. An installation can accept tokens with no expiry
(
DATAVAULT_API_TOKEN_ALLOW_NO_EXPIRY); by default, it does not. Tokens created before this rule have no expiry and keep working until the administrator rotates them. - Rotation. “Rotate”, in “Team → API tokens”, creates a new token with the same name and the
same scope. The old one keeps working during the grace period (24 hours by default), so the value
can be swapped in the pipeline without breaking it. “Revoke” cuts it off at once. The rotation goes
to the audit log (
token_rotated). - Last use. The token list shows each token's last use and last IP, updated at most once a minute. The IP stays in the database and only the administrator sees it: it does not go into the logs or the audit log.
- Warnings. A token active for more than 180 days raises an alert for the administrators. The weekly summary of the notification channels says how many tokens expire in the next 14 days.
Tests: backend/tests/test_api_token_lifecycle.py.
Organisation access restrictions
The organisation administrator sets, in “Team → Organisation → Access restrictions”, rules that
apply to every workspace in the organisation (backend/app/domain/organizations/access_policy.py):
- IP allowlist. A list of IPs and CIDR blocks, IPv4 and IPv6. With the list filled in, an IP
from outside is refused at sign-in (password, email links, the second MFA step and SSO), before the
session is issued, and on every authenticated request: open sessions, API tokens and the Microsoft
365 Copilot, which comes from Microsoft's network. The IP is the one the rate limit uses, so a
forged
X-Forwarded-Fordoes not get through. A refused person gets a message with the IP seen and what to do; a wrong password still returns the usual error, without revealing the policy. The session does not end: back on an allowed network, it carries on. - API tokens exempt by name. A token with the given name, in the given workspace, gets in from any network, for example CI with dynamic IPs. Each exemption carries the reason written by the administrator. Rotating the token, that is creating another with the same name and revoking the old one, keeps the exemption. A token with the same name in another workspace is not exempt.
- Maximum session length. Between 1 hour and the installation's validity (12 hours by default), counted from sign-in, with or without activity. Lowering it ends at once the sessions already past it; the sign-in screen says why.
- MFA mandatory across the organisation. People who sign in with a password must set up MFA at their next sign-in. People who sign in with SSO use their identity provider's MFA.
- Lock-out protection. Saving a list that does not include the IP of whoever saves it is refused.
If everyone is still locked out, for example because the office IP changed, the BeryxTech operator
turns the list off with
backend/scripts/clear_ip_allowlist.py, after confirming the request with the customer. - Every change goes to the audit log of every workspace in the organisation. Every refusal by IP goes to the audit log of the account's or token's workspace, and the section shows the organisation's latest 20 refusals. For a request from a session or token that is already authenticated, one refusal is kept per account, IP and origin every 15 minutes.
BeryxTech support access still follows the customer's consent (see Isolation between customers) and does not go through the customer organisation's allowlist.
Audit log
Administrative actions (roles, plans, connections, credentials, tokens, sessions, AI policy, AI actions
policy, support access) and assistant actions that are approved, requested or declined are kept
in an audit log written in the same transaction as the change, which workspace administrators can
query and export as CSV. Each event is kept for 400 days: a daily worker purge deletes older ones
and writes audit_log_purged to the log, with the cut-off and the count
(app/domain/audit/retention.py).
Subprocessors and providers
The list of subprocessors is public at /subprocessadores (pt/en): purpose, region, legal basis,
transfer mechanism and DPA of each one. A new provider, a removal or a change of region is
announced 30 days in advance. Administrators can receive the notice by email
(Compliance → Subprocessor notices). During the notice period, a new AI provider receives no data:
the router only sends to active subprocessors on the list. The risk assessment of each provider
(declared certifications, data it receives, exit plan) is in docs/compliance/vendors/.
Certifications: ISO 27001 and SOC 2
We do not have ISO 27001 certification or a SOC 2 report. They are part of a programme: the controls described in this document are the basis of that work. Until then, we answer RFP lines with the technical evidence above, not with badges — and we do not use "compliant" or "aligned with" as a substitute for a certification we do not have.
The public status of each certification (planned, in preparation, audit scheduled, obtained —
with dates) lives in a single file, docs/compliance/trust-status.json, which the site reads at
build time for the /datavault/seguranca page and for /.well-known/security.txt.
backend/tests/test_trust_status.py fails if that status diverges from this section or from
ADR 0010.
One of the controls already in operation is vulnerability management (compliance/vulnerability-management.md). It includes:
- SCA of the Python and npm dependencies and scanning of the images with Trivy on each PR;
- CI failure on critical/high without a recorded exception;
- an SLA of 7, 30 and 90 days by severity;
- a CycloneDX SBOM per deploy;
- a monthly report in
docs/compliance/evidence/; - weekly DAST (OWASP ZAP baseline, passive only) on the site, the app and the API, with a weekly report and the same exception register.
Findings from all sources (scanners, DAST, external pen test and responsible disclosure, through
/.well-known/security.txt) follow a process with an SLA
(compliance/security-findings.md).
We have not done an external pen test yet. The scope, the test accounts and the rules are ready
(compliance/pentest.md); we have yet to hire the provider.
What we do not do, and why
- Destructive remediation on the sources. We propose the fix (SQL/PySpark, or a pull request in the customer's repository); the customer applies it. Asking for write credentials would be objection no. 1 for any security team — it is a product decision, not a limitation.
- MDM / golden record with writes. We detect duplicates and propose consolidation, but we do not maintain a master record nor write the "winning" version to the sources.
- Multimodal image analysis. Documents are read through their text layer; images are not
processed, and a scanned PDF with no text is flagged as needing OCR
(
app/domain/quality/documents.py) instead of being sent to a vision model.