diff --git a/adr/ADR-007-Marking-based-access-control.md b/adr/ADR-007-Marking-based-access-control.md new file mode 100644 index 00000000000..6831daf6130 --- /dev/null +++ b/adr/ADR-007-Marking-based-access-control.md @@ -0,0 +1,191 @@ +# ADR-007: Marking-based access control + +| | | +| --- | --- | +| Status | Proposed | +| Related | https://github.com/OpenAEV-Platform/openaev/issues/7510 | +| Epic | https://github.com/OpenAEV-Platform/openaev/issues/7510 | +| Design docs | [`brainstorming/marking/`](../brainstorming/marking/) | +| Builds on | [ADR-002 — Multi-tenant data isolation strategy](./ADR-002-Multi-tenant-data-isolation-strategy.md) | + +## 1. Context + +OpenAEV today answers *"what may this user do?"* (capabilities, roles, groups) and *"which resources may +this user target?"* (grants). It has no answer to *"which resources is this user cleared to **see**?"* — +sensitivity is not modelled at all. Customers running a shared platform across teams of different +clearance need a red-team endpoint, a production credential or a sensitive asset group to be invisible to +users who are otherwise entitled to the feature. + +The industry answer, and the one the Filigran suite already speaks elsewhere, is STIX **marking +definitions** (TLP, PAP): a row carries zero, one or many markings; a user holds a clearance; a user sees +a row only if their clearance covers every marking on it. + +This lands on top of a live migration. ADR-002 introduced tenant isolation **v2**: a Hibernate +`StatementInspector` that rewrites every statement to add a `can_access_tenant(...)` predicate, driven by +a per-transaction Postgres GUC. That mechanism exists, is tested, and is being rolled out table by table. +Marking is a second sensitivity dimension over the same rows — so the first question is not *how to build +a filter* but *whether to reuse that enforcement point or build beside it*. + +Scope of the current epic is deliberately narrow: **Asset Group, Endpoint, Security Platform, Credential**. +Scenario, Simulation and Atomic Testing are a following epic. + +## 2. Decision drivers + +In priority order: + +1. **Enforcement must not be forgettable.** A missed marking check is a disclosure of exactly the data the + feature exists to protect. An option where a new query silently returns over-clearance rows is + disqualified on this driver alone. +2. **Cost per onboarded table must stay flat.** Four entity types this epic, three more next, and the + long tail after. An option that is cheap for four and linear thereafter loses. +3. **Consistency with ADR-002.** Two different isolation philosophies in one codebase is a maintenance and + incident-response cost, not just an aesthetic one. +4. **Blast radius of getting it wrong.** Marking sits below the whole read surface. Fail-closed must mean + "see less", never "see nothing", and never "see more". +5. **Time to market for the current epic.** + +Driver 1 outranks driver 5, and that is the trade-off this ADR makes explicit. + +## 3. Considered options + +### Option A: Service-layer marking filter + +A shared `MarkingAccessService` applied in service methods of the four marked types. + +**Pros**: fastest to deliver, explicit, easy to test, no migration risk. +**Cons**: enforcement happens where a developer *remembers* to call it. Every new read path is a new +opportunity to leak, silently. + +### Option B: Repository-level filtering, explicitly wired per query + +**B1** joins marking and group membership in each Specification/query. **B2** adds a precomputed clearance +so the read-time query stays cheap. + +**Pros**: harder to forget than A once written; filtering in SQL rather than in memory scales better. +**Cons**: same structural flaw as A — it is opt-in per query. Higher complexity, and B2 adds cache +invalidation risk on top. + +> A/B can be pushed a long way toward "centralised": a `Markable` interface, one shared specification, a +> hook in the pagination choke point, an AOP guard for non-paginated paths, and an ArchUnit rule that +> fails the build on an unguarded read. That last item is the only thing that makes A/B safe at scale, and +> it converts a silent leak into a build failure rather than eliminating it. Detail in +> [`tech-design.md`](../brainstorming/marking/tech-design.md). + +### Option C: Transparent statement rewrite, reusing the tenant v2 mechanism + +Generalise the ADR-002 enforcement point into a **scope dimension** abstraction, and add marking as a +second dimension alongside tenant: `ScopeStatementInspector` asks each active dimension for a predicate +and ANDs them. Clearance travels in its own GUC (`app.current_markings`) read by a new SQL function. + +**Pros**: forgetting is *structurally impossible* — enforcement sits below the code a developer writes. +Fail-closed by construction. One enforcement point. Onboarding a table is one property entry plus one +migration. +**Cons**: highest upfront complexity. It means modifying the sole enforcement point of multi-tenancy v2, +so tenant isolation carries regression risk. Two properties of marking make it *not* a drop-in reuse: +clearance is **ordinal** where tenant scope is set-membership, and marking is **many-to-many** where +`tenant_id` is a local column. Does not cover Elasticsearch-served reads. + +### Option D: Do nothing + +Rejected: the epic exists because the capability layer cannot express sensitivity. Deferring does not make +the problem cheaper — it makes the eventual retrofit larger, because every table added meanwhile is +another table to onboard. + +## 4. Decision + +We choose **Option C**, because driver 1 admits no other answer: it is the only option where a developer +who has never heard of markings cannot write a leaking query. + +**This decision is being validated by a PoC before it is committed to.** The status of this ADR is +`Proposed` and stays there until the PoC's definition of done is met. Options A and B remain the +fallbacks of record — the `Markable` contract and the shared-specification skeleton are the shape we +would retreat to. + +Concretely, Option C is composed of: + +- **`ScopeDimension`** — the abstraction extracted from the tenant inspector so a dimension can be added + without forking the rewrite skeleton. Tenant and marking are two implementations; the inspector ANDs + whichever are active on a table. A table may be on **tenant v1 `@Filter` and marking v2 at the same + time** — verified empirically, which is what lets marking activate on `assets` without waiting for the + tenant v2 rollout to reach it. +- **`marking_ids text[]`** — a column on the marked table, not a per-entity join table. This reverses an + earlier choice, argued in the design doc: the epic's real deliverable is a repeatable *activation + procedure*, and a procedure built on join tables is discarded the first time a relationship needs + marking (63 tables have composite primary keys). Join tables are retained as the fallback of record. +- **A negative SQL predicate.** Visibility is *"there is no marking on this row that I do not hold"* — a + `NOT EXISTS` anti-join over the array. Naming the function for the missing half keeps the generated SQL + free of a double negative, and makes unmarked rows visible for free with no `allow_unmarked` flag. The + positive formulation leaks and is pinned against by test. +- **`MarkingCtx`** — the clearance badge, mirroring ADR-002's `TxCtx` but with a different empty state. + `TxCtx.Missing` sees nothing; `MarkingCtx.None` still sees unmarked rows. Holding no clearance is a + normal, safe state. **Fail-closed for marking means "see less", not "see nothing".** +- **Ordinality resolved in Java, not SQL.** The resolver takes the granted marking ids plus the tenant's + scale, computes the maximum order *per type*, and expands back to every id at or below it. The result is + a flat set, so the SQL stays plain containment. A type with no grant contributes nothing — not even its + lowest level. +- **A clearance cache** read over raw JDBC, for the same reason `TenantMembershipCacheManager` is: it runs + on the pre-transaction argument-resolver path, where a JPA query would pin a pool connection for the + whole request. +- **Per-table activation** via `openaev.marking.active-tables`, and the activation procedure captured as a + repeatable skill — the same shape as ADR-002's runbook. + +Governance decisions (multi-marking is **AND**; unmarked is visible to all; `BYPASS` overrides; background +jobs see all markings; over-clearance direct `GET` returns **404** not 403, because a 403 leaks existence; +a user may not assign a marking they do not hold) are recorded with their reasoning in +[`tech-design-option-c.md` §6](../brainstorming/marking/tech-design-option-c.md). + +## 5. Consequences + +### Positive + +- Marking enforcement cannot be bypassed by forgetting it. New repositories, new native queries and joins + written by developers unaware of marking are all covered. +- Onboarding a table is a property entry plus a migration, so the cost stays flat as the next epic adds + Scenario, Simulation and Atomic Testing. +- One enforcement point, one incident-response story, one mental model shared with tenant isolation. +- Extracting `ScopeDimension` leaves the tenant mechanism better factored than it found it. + +### Negative / trade-offs + +- **We are modifying the sole enforcement point of multi-tenancy.** A marking bug can become a tenant bug. + The existing tenant inspector regression suite must stay green and unmodified — that is the gate on + every step, not a final check. +- **Slower to first delivery than Option A.** Accepted, on driver 1. +- **The fail-closed blast radius is paid at activation.** Activating marking on a table pulls every + statement touching it into parse-and-rewrite; an unsupported SQL shape is refused rather than run. + `assets` is deliberately activated first because it is the most-joined, and therefore the honest test. +- **Marking ends up stricter than tenant v1 on a shared table.** v1 `@Filter` covers neither bulk HQL nor + native queries; the inspector covers both. The table is then marking-isolated on paths where it is not + tenant-isolated. +- **Clearance caching fails open.** The SQL function is pure set containment and never consults + `marking_definitions`, so a stale *larger* cached clearance grants access that nothing downstream can + detect. Every reduction of a clearance must evict. This is a correctness requirement, not an + optimisation detail, and it is a gate on activating the first table. +- **Elasticsearch-served reads are not covered** by this mechanism and need their own answer. +- **Derived and denormalised data is out of scope for this epic** and is a known consequence: expectation + rows and native aggregate queries can surface information about assets the viewer cannot see. + +### Neutral + +- No REST contract changes: the clearance is derived in the aspect, so endpoint signatures are untouched. +- Background work deliberately sees all markings — jobs must execute against every asset, including marked + ones. Visibility and execution are separate knobs by design. +- The group-marking grant table is a clearance *grant*, never itself a marked table. +- Marking is additive to RBAC: capability, then grant, then clearance. A deny at any step denies. + +## 6. Further reading + +The working design documents live in [`brainstorming/marking/`](../brainstorming/marking/): + +| Document | Contents | +| --- | --- | +| [`user-stories.md`](../brainstorming/marking/user-stories.md) | Epic scope, user stories and acceptance criteria | +| [`tech-design.md`](../brainstorming/marking/tech-design.md) | Existing RBAC analysis, and the full A / B / C option comparison summarised in §3 | +| [`tech-design-option-c.md`](../brainstorming/marking/tech-design-option-c.md) | The chosen design in detail: data model and the schema-shape argument, runtime architecture, risks, and the decision log | +| [`implementation-plan-option-c.md`](../brainstorming/marking/implementation-plan-option-c.md) | Delivery plan, per-step status, and the PoC definition of done | + +> These are **working documents**, not a specification. They record the reasoning, including reversed +> decisions and open questions. This ADR is the stable summary; when the two disagree, the ADR is the one +> that was reviewed. Once the mechanism ships, the contributor-facing "how to work with it" page belongs in +> `docs/docs/development/`, next to +> [`tenant-isolation.md`](../docs/docs/development/tenant-isolation.md). diff --git a/brainstorming/README.md b/brainstorming/README.md new file mode 100644 index 00000000000..34af7c7edee --- /dev/null +++ b/brainstorming/README.md @@ -0,0 +1,29 @@ +# Brainstorming + +Working design documents: the reasoning *behind* a decision, while it is still being made. + +## What belongs here + +Exploratory and in-flight design material for a feature that is not yet built — option comparisons, +schema spikes, risk lists, open questions, delivery plans. These documents are **living**: they get +reversed, re-scoped and rewritten as the work teaches us things. + +## What does not + +| Kind | Home | Why | +| --- | --- | --- | +| The **decision** — options weighed, choice made, consequences accepted | [`adr/`](../adr/) | Short, reviewed, numbered, dated. Stable enough to cite in two years | +| **How to work with a shipped mechanism** | [`docs/docs/development/`](../docs/docs/development/) | Published to docs.openaev.io. Documents what exists, not what we intend | +| Reusable **agent prompts** | [`.github/prompts/`](../.github/prompts/) | Invoked as slash commands — a different kind of artifact entirely | + +The distinction that matters: an ADR is *reviewed and stable*, a brainstorming doc is *honest and +current*. When the two disagree, the ADR is the one that was reviewed. + +A feature typically produces one ADR and one folder here. Once it ships, the folder can be pruned and its +durable content promoted to `docs/docs/development/`. + +## Current + +| Folder | Feature | Decision | +| --- | --- | --- | +| [`marking/`](./marking/) | Marking-based access control (STIX TLP/PAP) for assets | [ADR-007](../adr/ADR-007-Marking-based-access-control.md) — Proposed | diff --git a/brainstorming/marking/demo/_common.sh b/brainstorming/marking/demo/_common.sh new file mode 100644 index 00000000000..4f9f9dbf33b --- /dev/null +++ b/brainstorming/marking/demo/_common.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# +# Shared plumbing for the marking admin helpers (mark-asset.sh, group-markings.sh). +# Not executable on its own - source it: +# +# . "$(dirname "$0")/_common.sh" +# +# demo.sh deliberately does NOT use this. A demo you hand to someone else should +# be one self-contained file they can read top to bottom, even at the cost of a +# little duplication. + +OPENAEV_URL="${OPENAEV_URL:-http://localhost:8080}" +TOKEN="${TOKEN:-5ccddea0-613c-4a91-a602-6a4eb243d21c}" +TENANT="${TENANT:-2cffad3a-0001-4078-b0e2-ef74274022c3}" + +admin=(-H "Authorization: Bearer ${TOKEN}" -H "Content-Type: application/json") + +die() { printf '\033[31m%s\033[0m\n' "$*" >&2; exit 1; } + +# Strip an optional [ ... ] wrapper and split on commas, so all of these work: +# +# TLP:GREEN PAP:RED +# [TLP:GREEN, PAP:RED] +# "TLP:GREEN,PAP:RED" +# +# Prints one name per line. Bracket form has to be quoted in most shells anyway, +# but accepting it unquoted too costs nothing and matches how people write a set +# when they are reading the design docs. +normalize_names() { + printf '%s\n' "$@" \ + | tr -d '[]' \ + | tr ',' '\n' \ + | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' \ + | grep -v '^$' || true +} + +# Fetch every marking definition in the tenant. Dies with something readable +# rather than letting the caller's python throw a traceback on an error body. +# `|| true` on curl so a connection failure (exit 7) does not trip `set -e` +# before the guard can run. +fetch_definitions() { + local body + body="$(curl -s "${admin[@]}" -X POST \ + "${OPENAEV_URL}/api/tenants/${TENANT}/marking-definitions/search" \ + -d '{"page":0,"size":200,"sorts":[{"property":"marking_order","direction":"asc"}]}' || true)" + + echo "$body" | python3 -c " +import sys, json +try: + d = json.load(sys.stdin) + assert 'content' in d +except Exception: + sys.exit(1)" 2>/dev/null \ + || die "could not read the marking definitions from ${OPENAEV_URL}. + Is the app up, and is TOKEN valid? Note MARKING_DEFINITION read currently + sits under ACCESS_TENANT_SETTINGS. The response was: +${body}" + + printf '%s' "$body" +} + +# resolve_markings [name ...] +# +# Prints two lines: the request payload, then a human label for the resolution. +# Case-insensitive, de-duplicated, and an unknown name lists what does exist - +# that is the mistake people actually make (there is no TLP:ORANGE). +resolve_markings() { + local field="$1"; shift + local definitions + definitions="$(fetch_definitions)" + + MARKINGS="$definitions" FIELD="$field" python3 - "$@" <<'PY' +import json, os, sys + +catalog = json.loads(os.environ["MARKINGS"])["content"] +field = os.environ["FIELD"] +by_name = {m["marking_name"].casefold(): m for m in catalog} + +ids, labels, missing = [], [], [] +for name in sys.argv[1:]: + hit = by_name.get(name.casefold()) + if hit is None: + missing.append(name) + continue + # The endpoint takes a set; the same id twice is a typo, not an intent. + if hit["marking_id"] not in ids: + ids.append(hit["marking_id"]) + labels.append(f'{hit["marking_name"]} ({hit["marking_id"]})') + +if missing: + known = ", ".join(sorted(m["marking_name"] for m in catalog)) + sys.exit(f'unknown marking(s): {", ".join(missing)}\ndefined in this tenant: {known}') + +print(json.dumps({field: ids})) +print(", ".join(labels)) +PY +} + +# Was the single argument the literal "none"? macOS ships bash 3.2, where the +# ${x,,} case-conversion expansions do not exist and fail at runtime with +# "bad substitution" - which `bash -n` does not catch. +is_clear_request() { + [ $# -eq 1 ] && [ "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" = "none" ] +} diff --git a/brainstorming/marking/demo/demo.sh b/brainstorming/marking/demo/demo.sh new file mode 100755 index 00000000000..02c216b43ed --- /dev/null +++ b/brainstorming/marking/demo/demo.sh @@ -0,0 +1,346 @@ +#!/usr/bin/env bash +# +# End-to-end PoC for marking-based access control on assets. +# +# Proves the three flows in one run, against a live dev stack: +# 3.1 group AMBER, asset GREEN -> the user SEES the asset +# 3.2 group AMBER, asset RED -> the user DOES NOT see it +# 3.3 admin raises group to RED -> the user sees it again, with no wait +# +# Plus the two guards that make the design safe: +# 3.4 assigning a marking above your own clearance is refused +# 3.5 the caller can never lock themselves out of an asset they just marked +# +# Mutation-checked: re-run with the dimension switched off and 3.2 flips from +# 404 to 200. +# +# mvn -o -pl openaev-api spring-boot:run -Dspring-boot.run.profiles=dev \ +# -Dspring-boot.run.arguments=--openaev.marking.active-tables= +# +# Read the result honestly: 3.2 is the ONLY assertion that discriminates on its +# own - it is the one that proves rows are actually being filtered. The others +# are consistency checks that hold either way. The 3.2 -> 3.3 pair is what +# proves the cache eviction: the same user, same asset, goes 404 then 200 with +# nothing changing in between except the grant write. +# +# Prerequisites: dev stack up, app running on :8080 with +# openaev.marking.active-tables=assets +# +set -euo pipefail + +OPENAEV_URL="${OPENAEV_URL:-http://localhost:8080}" +TOKEN="${TOKEN:-5ccddea0-613c-4a91-a602-6a4eb243d21c}" +TENANT="${TENANT:-801b2e9e-9407-405f-9c61-21200c500311}" +# The vite dev server (`yarn start` in openaev-front, port 3001) is the default +# because it always matches the working tree. :8080 also serves a UI, but only +# whatever was last copied into openaev-front/builder/prod/build - which can be +# months stale. Override FRONT_URL if you want the bundled one. +FRONT_URL="${FRONT_URL:-http://localhost:3001}" +# DEMO=1 stops at each UI checkpoint so you can show the asset list between +# steps. DEMO=0 runs straight through - use it when piping the output, and for +# the mutation check described above. +DEMO="${DEMO:-1}" + +admin=(-H "Authorization: Bearer ${TOKEN}" -H "Content-Type: application/json") +SUFFIX="$(date +%s)" +PASS=0 +FAIL=0 + +say() { printf '\n\033[1m%s\033[0m\n' "$*"; } +ok() { PASS=$((PASS + 1)); printf ' \033[32mPASS\033[0m %s\n' "$*"; } +bad() { FAIL=$((FAIL + 1)); printf ' \033[31mFAIL\033[0m %s\n' "$*"; } +check() { [ "$1" = "$2" ] && ok "$3 (got $1)" || bad "$3 (expected $2, got $1)"; } + +# Reads from /dev/tty rather than stdin, so the pause still works when the +# script is piped (./demo.sh | tee demo.log). Skipped entirely when +# DEMO=0 or when there is no terminal to read from, so an unattended run can +# never hang. `|| true` because read returns non-zero on EOF and set -e is on. +# The message comes in on stdin (a heredoc), not as an argument. The obvious +# `pause "$(cat <>> %s\033[0m\n' "$msg" + [ "$DEMO" = "1" ] || return 0 + # `[ -r /dev/tty ]` is not enough: the node exists and looks readable even + # when there is no controlling terminal to open (cron, CI, a detached shell). + # Actually opening it is the only reliable probe. + { exec 3/dev/null || return 0 + printf '\033[2m press Enter to continue\033[0m' + read -r _ <&3 || true + exec 3<&- + echo +} + +jqr() { python3 -c "import sys,json;d=json.load(sys.stdin);print($1)"; } + +# -------------------------------------------------------------------------- +say "0. Resolving the seeded marking definitions" + +markings="$(curl -s "${admin[@]}" -X POST \ + "${OPENAEV_URL}/api/tenants/${TENANT}/marking-definitions/search" \ + -d '{"page":0,"size":50,"sorts":[{"property":"marking_order","direction":"asc"}]}')" + +pick() { echo "$markings" | python3 -c " +import sys,json +d=json.load(sys.stdin) +print(next(x['marking_id'] for x in d['content'] if x['marking_name']=='$1'))"; } + +M_GREEN="$(pick 'TLP:GREEN')" +M_AMBER="$(pick 'TLP:AMBER')" +M_RED="$(pick 'TLP:RED')" +echo " TLP:GREEN=${M_GREEN}" +echo " TLP:AMBER=${M_AMBER}" +echo " TLP:RED =${M_RED}" + +# -------------------------------------------------------------------------- +say "1. Creating a non-admin user, a group, and an asset" +# Admin bypasses marking filtering entirely (isAdminOrBypass), so the whole +# demo has to run as a plain user - otherwise every flow trivially "passes". + +USER_EMAIL="corinne-poc-marking-${SUFFIX}@openaev.io" +user="$(curl -s "${admin[@]}" -X POST "${OPENAEV_URL}/api/tenants/${TENANT}/users" \ + -d "{\"user_email\":\"${USER_EMAIL}\",\"user_firstname\":\"Poc\",\"user_lastname\":\"Marking\",\"user_admin\":false,\"user_plain_password\":\"c\"}")" +USER_ID="$(echo "$user" | jqr "d['user_id']")" +USER_TOKEN="$(docker exec openaev-dev-pgsql psql -U openaev -d openaev -tA \ + -c "select token_value from tokens where token_user = '${USER_ID}'" | tr -d '[:space:]')" +member=(-H "Authorization: Bearer ${USER_TOKEN}" -H "Content-Type: application/json") +echo " user = ${USER_ID} ${USER_EMAIL} (plain password 'c')" + +group="$(curl -s "${admin[@]}" -X POST "${OPENAEV_URL}/api/tenants/${TENANT}/groups" \ + -d "{\"group_name\":\"PoC Marking ${SUFFIX}\"}")" +GROUP_ID="$(echo "$group" | jqr "d['group_id']")" +echo " group = ${GROUP_ID} PoC Marking ${SUFFIX}" + +curl -s -o /dev/null "${admin[@]}" -X PUT \ + "${OPENAEV_URL}/api/tenants/${TENANT}/groups/${GROUP_ID}/users" \ + -d "{\"group_users\":[\"${USER_ID}\"]}" + +# The group needs RBAC on assets, otherwise RBAC - not markings - is what hides +# the row, and every flow below would "pass" for the wrong reason. Manager gives +# ACCESS_ASSETS + MANAGE_ASSETS and, critically, NOT bypass: a bypass user skips +# marking filtering entirely (isAdminOrBypass) and would prove nothing. +roles="$(curl -s "${admin[@]}" -X POST "${OPENAEV_URL}/api/tenants/${TENANT}/roles/search" \ + -d '{"page":0,"size":50}')" +ROLE_MANAGER="$(echo "$roles" | jqr "next(x['role_id'] for x in d['content'] if x['role_name']=='Manager')")" + +# Second role: reading marking definitions currently sits under +# ACCESS_TENANT_SETTINGS (Capability.java, MARKING_DEFINITION READ/SEARCH), which +# Manager does not have. Without it the member gets 403 on +# /marking-definitions/search, so the UI cannot turn the ids in asset_markings +# into names and colours and the Markings column renders empty - even though the +# ids are right there in the payload. +# +# This is a PoC shortcut. Markings are reference data that any user who can see a +# marked row needs to read, exactly like tags, which have their own ACCESS_TAGS in +# CapabilityGroup.TAXONOMY. The real fix is a dedicated ACCESS_MARKINGS capability; +# until then the demo grants ACCESS_TENANT_SETTINGS through a throwaway role. +# +# A separate role rather than editing the seeded Manager: Manager is shared by +# every user of this dev database, and widening it here would silently persist +# after the demo and quietly weaken any later test that assumes stock Manager. +role_reader="$(curl -s "${admin[@]}" -X POST "${OPENAEV_URL}/api/tenants/${TENANT}/roles" \ + -d "{\"role_name\":\"PoC Marking Reader ${SUFFIX}\",\"role_capabilities\":[\"ACCESS_TENANT_SETTINGS\"]}")" +ROLE_READER="$(echo "$role_reader" | jqr "d['role_id']")" + +curl -s -o /dev/null "${admin[@]}" -X PUT \ + "${OPENAEV_URL}/api/tenants/${TENANT}/groups/${GROUP_ID}/roles" \ + -d "{\"group_roles\":[\"${ROLE_MANAGER}\",\"${ROLE_READER}\"]}" +echo " roles = Manager (ACCESS_ASSETS + MANAGE_ASSETS, no BYPASS)" +echo " + PoC Marking Reader (ACCESS_TENANT_SETTINGS - lets the UI resolve marking ids)" + +ASSET_NAME="poc-marking-${SUFFIX}" +endpoint="$(curl -s "${admin[@]}" -X POST "${OPENAEV_URL}/api/tenants/${TENANT}/endpoints/agentless" \ + -d "{\"asset_name\":\"${ASSET_NAME}\",\"endpoint_hostname\":\"poc-${SUFFIX}\",\"endpoint_ips\":[\"10.0.0.1\"],\"endpoint_platform\":\"Linux\",\"endpoint_arch\":\"x86_64\"}")" +ASSET_ID="$(echo "$endpoint" | jqr "d['asset_id']")" +echo " asset = ${ASSET_ID} with name: ${ASSET_NAME}" + +# ---- UI checkpoint 1 ------------------------------------------------------- +pause < ${ASSET_NAME} +EOF + +# -------------------------------------------------------------------------- +say "2. Granting the group TLP:AMBER" + +curl -s -o /dev/null "${admin[@]}" -X PUT \ + "${OPENAEV_URL}/api/tenants/${TENANT}/groups/${GROUP_ID}/markings" \ + -d "{\"group_markings\":[\"${M_AMBER}\"]}" + +# The grant is AMBER; the clearance it resolves to also contains GREEN and +# CLEAR. That expansion happens in Java, once per request, so the SQL +# predicate stays a flat containment test with no notion of order. +echo " granted TLP:AMBER -> clearance covers CLEAR, GREEN, AMBER" + +sees() { curl -s -o /dev/null -w '%{http_code}' "${member[@]}" \ + "${OPENAEV_URL}/api/tenants/${TENANT}/endpoints/${ASSET_ID}"; } + +mark_asset() { curl -s -o /dev/null -w '%{http_code}' "${admin[@]}" -X PUT \ + "${OPENAEV_URL}/api/tenants/${TENANT}/assets/${ASSET_ID}/markings" \ + -d "{\"asset_markings\":[$1]}"; } + +# ---- UI checkpoint 2 ------------------------------------------------------- +pause < ${ASSET_NAME} (expect: still listed, Markings empty) +EOF + +# -------------------------------------------------------------------------- +say "3.0 Baseline: unmarked asset is visible" +check "$(sees)" "200" "unmarked asset is visible (empty set is inside every clearance)" + +say "3.1 Group AMBER, asset GREEN -> the user SEES it" +check "$(mark_asset "\"${M_GREEN}\"")" "200" "asset marked TLP:GREEN" +check "$(sees)" "200" "GREEN asset visible to an AMBER clearance" +# ---- UI checkpoint ---------------------------------------------------------- +pause < ${ASSET_NAME} (expect: listed, chip TLP:GREEN) +EOF + + +say "3.2 Group AMBER, asset RED -> the user DOES NOT see it" +check "$(mark_asset "\"${M_RED}\"")" "200" "asset marked TLP:RED" +check "$(sees)" "404" "RED asset hidden from an AMBER clearance" +# ---- UI checkpoint ---------------------------------------------------------- +pause < ${ASSET_NAME} (expect: ABSENT for the user, present for admin) +EOF + + +say "3.3 Admin raises the group to TLP:RED -> visible again, no wait" +curl -s -o /dev/null "${admin[@]}" -X PUT \ + "${OPENAEV_URL}/api/tenants/${TENANT}/groups/${GROUP_ID}/markings" \ + -d "{\"group_markings\":[\"${M_RED}\"]}" +check "$(sees)" "200" "cached clearance was evicted on the grant write - no TTL wait" +# ---- UI checkpoint ---------------------------------------------------------- +pause < ${ASSET_NAME} (expect: listed again, chip TLP:RED) +EOF + + +say "3.4 Guard: a user cannot assign a marking above their own clearance" +# Drop the group back to GREEN, then have the member try to assign RED. +curl -s -o /dev/null "${admin[@]}" -X PUT \ + "${OPENAEV_URL}/api/tenants/${TENANT}/groups/${GROUP_ID}/markings" \ + -d "{\"group_markings\":[\"${M_GREEN}\"]}" +curl -s -o /dev/null "${admin[@]}" -X PUT \ + "${OPENAEV_URL}/api/tenants/${TENANT}/assets/${ASSET_ID}/markings" \ + -d '{"asset_markings":[]}' +escalation="$(curl -s -o /dev/null -w '%{http_code}' "${member[@]}" -X PUT \ + "${OPENAEV_URL}/api/tenants/${TENANT}/assets/${ASSET_ID}/markings" \ + -d "{\"asset_markings\":[\"${M_RED}\"]}")" +check "$escalation" "403" "assigning TLP:RED with only a GREEN clearance is refused" +# ---- UI checkpoint ---------------------------------------------------------- +pause < ${ASSET_NAME} (expect: listed, Markings empty) +EOF + + +say "3.5 Guard: the caller cannot lock themselves out" +selfmark="$(curl -s -o /dev/null -w '%{http_code}' "${member[@]}" -X PUT \ + "${OPENAEV_URL}/api/tenants/${TENANT}/assets/${ASSET_ID}/markings" \ + -d "{\"asset_markings\":[\"${M_GREEN}\"]}")" +check "$selfmark" "200" "assigning a marking inside your own clearance succeeds" +check "$(sees)" "200" "the asset you just marked is still visible to you" +# ---- UI checkpoint ---------------------------------------------------------- +pause < ${ASSET_NAME} (expect: listed, chip TLP:GREEN) +EOF + + +# -------------------------------------------------------------------------- +# Read both sides of the containment test straight out of Postgres, bypassing the +# API. Everything above infers marking state from HTTP status codes; a silently +# broken write path could produce the same 200/404 sequence with nothing actually +# persisted. This is the independent check. +# +# The visibility rule is: row_markings SUBSET OF clearance. So the two things +# worth printing are the row's own markings and the grant the clearance is +# resolved from - they are opposite sides of the same test, not the same thing. +say "4. Both sides of the containment test, read directly from Postgres" +docker exec openaev-dev-pgsql psql -U openaev -d openaev -tA -c " + select 'row markings (assets.marking_ids) : ' || + coalesce((select string_agg(m.marking_name, ', ' order by m.marking_order) + from marking_definitions m + where m.marking_id = any(a.marking_ids)), '(none)') + from assets a where a.asset_id = '${ASSET_ID}' + union all + select 'group grant (groups_markings) : ' || + coalesce(string_agg(m.marking_name, ', ' order by m.marking_order), '(none)') + from groups_markings gm + join marking_definitions m on m.marking_id = gm.marking_id + where gm.group_id = '${GROUP_ID}'" +echo " the grant expands downward in Java (AMBER also grants GREEN and CLEAR);" +echo " the SQL predicate itself is a flat subset test with no notion of order." + +say "Result: ${PASS} passed, ${FAIL} failed" +[ "$FAIL" -eq 0 ] diff --git a/brainstorming/marking/demo/group-markings.sh b/brainstorming/marking/demo/group-markings.sh new file mode 100755 index 00000000000..6ef29a3a8d2 --- /dev/null +++ b/brainstorming/marking/demo/group-markings.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# +# Set the markings a GROUP grants its members, as admin, on the default tenant. +# +# ./group-markings.sh TLP:GREEN # one +# ./group-markings.sh TLP:GREEN PAP:RED # several +# ./group-markings.sh "[TLP:GREEN, PAP:RED]" # bracket form, QUOTED +# ./group-markings.sh none # revoke every grant +# +# NOTE ON THE BRACKET FORM: [TLP:RED] is a glob pattern in zsh (the macOS +# default) and in bash, so it must be QUOTED. Unquoted it fails before this +# script is ever invoked: +# +# ./group-markings.sh [TLP:RED] -> zsh: no matches found: [TLP:RED] +# ./group-markings.sh "[TLP:RED]" -> fine +# +# Worse, if a single-character file happens to exist in the current directory, +# zsh matches it and silently passes THAT instead. The plain space-separated +# form has no such hazard, which is why it is listed first. +# +# This is the WRITE side of the model: a group's grants are what every member's +# clearance is resolved from. Compare mark-asset.sh, which marks the rows that +# clearance is then tested against. +# +# Two things worth knowing about what this does downstream: +# +# * The grant EXPANDS DOWNWARD when it is resolved. Granting TLP:AMBER yields a +# clearance covering AMBER, GREEN and CLEAR. The expansion happens in Java, +# once per request, so the SQL predicate stays a flat subset test with no +# notion of order. Do not grant the lower ones by hand. +# +# * Every member's cached clearance is EVICTED on write, so the change lands on +# their very next request - no TTL to wait out. That is what demo.sh flow 3.3 +# proves: the same user, same asset, goes 404 then 200 with nothing changing +# in between except this call. +# +# Replaces the WHOLE set, like the sibling users and roles endpoints, so clearing +# is spelled "none" rather than by omitting the argument: an accidental +# `./group-markings.sh ` must not silently revoke a group's clearance. +# +set -euo pipefail +. "$(dirname "$0")/_common.sh" + +[ $# -ge 2 ] || die "usage: $(basename "$0") TLP:GREEN [PAP:RED ...] + $(basename "$0") \"[TLP:GREEN, PAP:RED]\" <- brackets MUST be quoted + $(basename "$0") none # revoke every grant" + +GROUP_ID="$1"; shift + +names=() +while IFS= read -r n; do names+=("$n"); done < <(normalize_names "$@") + +if is_clear_request "${names[@]+"${names[@]}"}"; then + PAYLOAD='{"group_markings":[]}' + WANTED="(none - revoking every grant)" +else + [ ${#names[@]} -gt 0 ] || die "no marking names given" + resolved="$(resolve_markings group_markings "${names[@]}")" || die "$resolved" + PAYLOAD="$(echo "$resolved" | head -1)" + WANTED="$(echo "$resolved" | tail -1)" +fi + +echo "group ${GROUP_ID}" +echo "grants ${WANTED}" + +response="$(curl -s -w '\n%{http_code}' "${admin[@]}" -X PUT \ + "${OPENAEV_URL}/api/tenants/${TENANT}/groups/${GROUP_ID}/markings" \ + -d "$PAYLOAD")" +code="$(echo "$response" | tail -1)" +body="$(echo "$response" | sed '$d')" + +case "$code" in + 200) + echo "$body" | python3 -c " +import sys, json +d = json.load(sys.stdin) +n = len(d.get('group_markings') or []) +print(f'\033[32mOK\033[0m {d[\"group_name\"]} now grants {n} marking(s); member clearances evicted')" + ;; + 403) + die "403 - refused. A caller may only grant markings inside its own clearance + (MarkingEscalationValidator), and only markings defined in this tenant. + Note granting a LOWER marking than you hold is allowed: you can already + read those rows, so granting them discloses nothing new." + ;; + 404) + die "404 - no such group in tenant ${TENANT}." + ;; + *) + die "HTTP ${code} +${body}" + ;; +esac diff --git a/brainstorming/marking/demo/mark-asset.sh b/brainstorming/marking/demo/mark-asset.sh new file mode 100755 index 00000000000..0874b1307e9 --- /dev/null +++ b/brainstorming/marking/demo/mark-asset.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# +# Set the markings carried by one ASSET, as admin, on the default tenant. +# +# ./mark-asset.sh TLP:GREEN # one +# ./mark-asset.sh TLP:GREEN PAP:RED # several +# ./mark-asset.sh "[TLP:GREEN, PAP:RED]" # bracket form, QUOTED +# ./mark-asset.sh none # clear every marking +# +# NOTE ON THE BRACKET FORM: [TLP:RED] is a glob pattern in zsh (the macOS +# default) and in bash, so it must be QUOTED. Unquoted it fails before this +# script is ever invoked: +# +# ./mark-asset.sh [TLP:RED] -> zsh: no matches found: [TLP:RED] +# ./mark-asset.sh "[TLP:RED]" -> fine +# +# Worse, if a single-character file happens to exist in the current directory, +# zsh matches it and silently passes THAT instead. The plain space-separated +# form has no such hazard, which is why it is listed first. +# +# Marking names are the human ones - TLP:GREEN, PAP:AMBER - resolved to ids here +# so you never paste a UUID. Case-insensitive. +# +# This is the READ side of the model: an asset's markings are what a clearance is +# tested against. Compare group-markings.sh, which sets the clearance itself. +# +# The endpoint replaces the WHOLE set, so this script does too. That is why +# clearing is spelled "none" rather than by omitting the argument - an +# accidental `./mark-asset.sh ` must not silently declassify an asset. +# +# Runs as admin on purpose: admin skips marking filtering (isAdminOrBypass), so +# it can always see and re-mark an asset, including one marked above its own +# head. A non-admin caller would additionally hit MarkingEscalationValidator. +# +set -euo pipefail +. "$(dirname "$0")/_common.sh" + +[ $# -ge 2 ] || die "usage: $(basename "$0") TLP:GREEN [PAP:RED ...] + $(basename "$0") \"[TLP:GREEN, PAP:RED]\" <- brackets MUST be quoted + $(basename "$0") none # clear every marking" + +ASSET_ID="$1"; shift + +names=() +while IFS= read -r n; do names+=("$n"); done < <(normalize_names "$@") + +if is_clear_request "${names[@]+"${names[@]}"}"; then + PAYLOAD='{"asset_markings":[]}' + WANTED="(none - clearing every marking)" +else + [ ${#names[@]} -gt 0 ] || die "no marking names given" + resolved="$(resolve_markings asset_markings "${names[@]}")" || die "$resolved" + PAYLOAD="$(echo "$resolved" | head -1)" + WANTED="$(echo "$resolved" | tail -1)" +fi + +echo "asset ${ASSET_ID}" +echo "markings ${WANTED}" + +response="$(curl -s -w '\n%{http_code}' "${admin[@]}" -X PUT \ + "${OPENAEV_URL}/api/tenants/${TENANT}/assets/${ASSET_ID}/markings" \ + -d "$PAYLOAD")" +code="$(echo "$response" | tail -1)" +body="$(echo "$response" | sed '$d')" + +case "$code" in + 200) + echo "$body" | python3 -c " +import sys, json +d = json.load(sys.stdin) +print(f'\033[32mOK\033[0m {d[\"asset_name\"]} now carries {len(d.get(\"asset_markings\") or [])} marking(s)')" + ;; + 403) + die "403 - refused. A caller may only assign markings inside its own clearance + (MarkingEscalationValidator), and only markings defined in this tenant." + ;; + 404) + die "404 - no such asset in tenant ${TENANT}. + Note this is also what you get for an asset marked ABOVE your clearance: + filtering happens below authorization, so the two are indistinguishable + on purpose. As admin that should not apply, so suspect the id." + ;; + *) + die "HTTP ${code} +${body}" + ;; +esac diff --git a/brainstorming/marking/implementation-plan-option-c.md b/brainstorming/marking/implementation-plan-option-c.md new file mode 100644 index 00000000000..1f4cd37429c --- /dev/null +++ b/brainstorming/marking/implementation-plan-option-c.md @@ -0,0 +1,396 @@ +# Option C — implementation plan + +**Design doc**: [tech-design-option-c.md](./tech-design-option-c.md) — the mechanism and its rationale +**Parent doc**: [tech-design.md](./tech-design.md) — Option C +**Decision of record**: [ADR-007](../../adr/ADR-007-Marking-based-access-control.md) +**Status**: in progress — steps 1.1–1.4 and 2.1–2.4 delivered; step 3 next + +> **Reference convention.** `§N.M` always refers to a section of +> [tech-design-option-c.md](./tech-design-option-c.md); `Qn` refers to its §6 Decisions table. +> Plain numbers such as *step 2.2* or *5.6* are steps of the plan below. + +--- + +## 1. Delivery plan + +Feature flag: `marking-isolation`. Property `openaev.marking.active-tables` empty by default. + +**Two tracks run in parallel, then converge:** + +``` +Track 1 (inspector) 1.1 ──► 1.2 ──► 1.3 ──► 1.4 ─┐ + no dependencies ├──► 3 (activation) ──► 4 ──► 5 +Track 2 (channel) 2.1 ──► 2.2 ──► 2.3 ──► 2.4 ─┘ + external teams +``` + +**Status at a glance** + +| Step | | Commit | +|---|---|---| +| 1.1 `ScopeDimension` extraction | ✅ done | `4100dac` | +| 1.2 marking SQL function | ✅ done, superseded by 1.4 | | +| 1.3 `MarkingDimension` + allowlist | ✅ done, reshaped by 1.4 | | +| 1.4 schema-shape spike → **Option 2 adopted** | ✅ done | `a0dcbd471c` | +| 2.1 `marking_definitions` schema + CRUD + UI | ✅ done | `3033c7759b` | +| 2.2 `MarkingCtx` + resolver + cache + eviction wiring | ✅ done | | +| 2.3 marking scope written on both transaction paths | ✅ done | | +| 2.4 `groups_markings` write path (assign endpoint + escalation guard) | ✅ done — lifted the step-3 gate | | +| 3 activate `assets` | ⬅️ **next** | | +| 4 `activate-marking-table` skill — *the PoC's real deliverable* | pending | | +| 5 go-live hardening | out of PoC | | + +### Step 1 (Track 1) — Inspector (starts immediately, no dependencies) + +**1.1 — ✅ DONE** (commit `4100dac`) — Extract `ScopeDimension`; refactor `TenantStatementInspector` → +`ScopeStatementInspector`, tenant-only, zero behaviour change. *(deps: none)* + - **Context**: pure refactor; `TenantDimension` wraps today's `TenantTables` + `can_access_tenant` call. + - **DoD**: the entire existing tenant test suite green, **unmodified**. No marking test in this commit. + Land it on a clean base, before `TxCtx` is reshaped by 2.3, so a bisect stays readable. + - **Delivered**: `TenantStatementInspector` is now an 18-line subclass, so all existing wiring and the + 79 tenant inspector tests are untouched. `ScopeStatementInspectorTest` pins the composition contract. + +**1.2 — ✅ DONE, ⚠️ superseded by 1.4 — `is_marking_missing()` migration** — Flyway Java migration +mirroring `V5_27`. Belongs to the **Option 1 fallback**; under Option 2 it is replaced by +`is_marking_set_allowed(text[])`, which takes the whole set instead of one id. *(deps: none)* + - **Context**: statement inspector → `is_marking_missing(am.marking_id)` → reads `app.current_markings`. + - **DoD**: integration test on the truth table (GUC unset / GUC empty / id in set / id out of set). + - **Delivered**: `V6_20260825090000000__Add_is_marking_missing_function` + `IsMarkingMissingFunctionTest` + (6 cases, incl. exact-match-only and a null marking id). + +**1.3 — ✅ DONE, ⚠️ `MarkedTable` reshaped by 1.4 — `MarkingDimension` + +`openaev.marking.active-tables` (empty) + `MarkedTable` schema derivation.** *(deps: 1.1, 1.2)* + - **DoD**: with an empty allowlist, emitted SQL is byte-identical to before (pinned by test); unknown + table name in the property fails startup. **Plus the hypothesis test**: a fixture table + join table, + GUC set manually via `set_config`, asserting unmarked-visible / in-clearance-visible / + out-of-clearance-hidden / multi-marking-AND — no resolver, no product code, no Task 1 dependency. + - **Delivered**: `MarkedTable` / `MarkedTables` / `MarkingDimension` / `MarkingFilteringConfig`, plus + `ScopeFilteringConfig` which now owns the single Hibernate inspector composed of both dimensions + (`TenantFilteringConfig` only contributes the `TenantDimension` bean). `MarkingDimensionTest` (12 cases) + and `MarkingRewriteHypothesisTest` (6 cases on real rows, including a guarded UPDATE). + - **Deviation from the sketch in §4.1**: derivation lives in its own `MarkingFilteringConfig` next to + `TenantFilteringConfig` rather than in a single `ScopeFilteringConfig`; `ScopeFilteringConfig` is kept + to the one thing that must be central — composing and installing the inspector. + +> **Go/no-go gate**: ✅ **passed.** 1.1 and 1.3 landed with the tenant suite green and unmodified, and the +> hypothesis test proves the four M2M behaviours on real rows. + +**1.4 — 🔴 NEXT — schema-shape spike: validate Option 2 (`marking_ids text[]`) before any activation work.** +*(deps: 1.3, blocks 3 and 4)* + +§3.2 chose Option 2 on argument; 1.1–1.3 validated Option 1 on evidence. This step equalises them before a +single table is activated, because activating on the wrong shape is what makes the choice expensive. + + - **Build**: `is_marking_set_allowed(text[])` migration; a second `MarkingDimension` implementation whose + `readPredicate` is the one-liner `is_marking_set_allowed(.marking_ids)`; `MarkedTable` degenerated + to `(table, markingColumn)`. + - **DoD** — port `MarkingRewriteHypothesisTest` to the `<@` predicate and assert: + 1. the **identical truth table** to §4.4 on real rows — the same four M2M behaviours, unchanged; + 2. **fail-closed** on GUC unset, GUC empty, `NULL` array and `'{}'` array; + 3. a **regression test that pins the fail-open trap**: demonstrate that the `&&`-on-lacked form leaks a + row carrying a marking created mid-session, so nobody "optimises" into it later; + 4. composite-PK viability: the predicate applies unchanged to a fixture table with a two-column PK — + the property Option 1 cannot deliver; + 5. `EXPLAIN (ANALYZE, BUFFERS)` for both predicates on seeded `assets`, with and without the GIN index, + recorded in §5.5. + - **Exit**: if all five hold, Option 2 is confirmed — drop 1.2's function, reshape `MarkedTable`, and + proceed. **If any fail, revert to Option 1**, which is already built and green; only this step is lost. + - *This is the cheapest moment to be wrong. After step 3 the cost includes a data migration; after step 4 + it also includes rewriting the skill and every table activated through it.* + +### Step 2 (Track 2) — Scope channel (blocked on a **skimmed** Task 1 / Task 2) + +> **Scope call**: the PoC needs a *clearance to read*, not a governed marking product. Task 1 and Task 2 +> are therefore taken in a **skimmed** form: enough schema and plumbing to populate +> `app.current_markings`, and nothing else. Capabilities, RBAC and the assignment UX come back in step 5, +> once step 3 has proven the mechanism is worth building them on. + +**2.1 — Skimmed prerequisites** — ✅ **DONE** + - `marking_definitions` table + entity + **plain CRUD** (id, type, name, order, colour). TLP + PAP + defaults seeded per tenant, both by migration and for tenants created later. + - `groups_markings` join table. **The assign/unassign endpoint was deliberately NOT built here**: + it is only consumed by 2.2, so it lands there, where a test can prove it does something. + - **Explicitly NOT in this step**: the "Marking definitions" and "Assign marking" capability chains, + a dedicated `@AccessControl` subject, RBAC beyond reusing `*_TENANT_SETTINGS`, the assignment UX. + Deferred to 5.6. + - **Frontend pulled forward from 5.6** at the maintainer's request: a CRUD screen under + *Settings → Security → Marking definitions*. Reason: the vocabulary is what a human has to see to + reason about step 3, and it is cheap — no new capability, no new permission subject. + - **Tenant isolation is v2** (statement inspector + `can_access_tenant`), not v1 `@Filter`. A + brand-new table has no legacy read paths, so there is nothing to migrate, and 2.3 composes + cleanly on top. + + **Delivered** + + | Artefact | Note | + |---|---| + | `V6_20260825140000000__Add_marking_definitions.java` | both tables, composite unique `(marking_name, tenant_id)`, 9 seeds × tenant | + | `MarkingDefinition` + `MarkingDefinitionRepository` | `TenantBase`, **no** `@Filter`, **no** `TenantBaseListener` | + | `api/markings/` — Api, Service, Mapper, QueryHelper, Input, Output | every handler takes `TxCtx`; writes attributed via `TenantWriteScopeResolver` | + | `MarkingDefinitionDependenciesManager` | seeds defaults for tenants created after the migration | + | `application.properties` | `marking_definitions` appended to `openaev.tenant.active-tables` | + | frontend `marking_definitions/` + `SecurityMenu` + route + 9 lang files | `Can I={MANAGE} a={TENANT_SETTINGS}` | + | `MarkingDefinitionFixture`, `MarkingDefinitionComposer` | unique names — the 9 seeds per tenant make name reuse a collision | + | `MarkingDefinitionApiTest` (14), `MarkingDefinitionHttpIsolationTest` (10) | isolation covers read/create/update/delete, each with a **positive and a negative** case | + + **DoD met**: 474 tests green across `Marking*`/`Scope*`/`Tenant*`, including the 29 tenant arch + tests and the untouched 79-case `TenantStatementInspectorTest`. Verified live: an unscoped admin + read returns the union across tenants (standard v2 semantic), a read carrying `X-Tenant-Ids` + returns exactly that tenant's 9 markings. + + **Two findings worth carrying forward** + - `assertNameIsFree` originally used a single-result `findByName`, but the unique index is + composite on `(marking_name, tenant_id)` — several tenants legitimately own `TLP:RED`. It was + only safe *because* the inspector scopes the query, which means it would have thrown the moment + the table was inactive. Changed to a `List`-returning finder. **This is a general trap for any + v2 table**: a derived single-result finder on a per-tenant-unique column is a latent failure, + and it belongs in the `activate-marking-table` skill (step 4.1). + - A cross-tenant `PUT`/`DELETE` returns **404**, not the 2xx no-op the v2 skill describes, because + the service does a scoped `findById` before writing. Both are safe; the divergence is worth + stating explicitly so a future reader does not read 404 as a bug. + +> **Why `groups_markings` is *not* itself marking-activated** — and stays that way until after step 3. +> It is the table the clearance is *derived from*. Marking-filtering it would make the resolver's own read +> depend on a clearance that does not exist yet: a circular dependency that fails closed to "no clearance", +> i.e. every marked row invisible to everyone. Activating it is a deliberate, separate decision (5.6), not +> an oversight. + +**2.2 — `MarkingCtx` + `MarkingScopeResolver` + `MarkingClearanceCache`** — groups → markings → max order +per type → expanded id set. Pure Java, no SQL. *(deps: 2.1)* + - **DoD**: unit tests for highest-wins across groups, per-type independence, empty clearance, + admin/BYPASS; cache eviction test per §5.4. + - 🔴 **Cache invalidation is a correctness requirement, not an optimisation detail.** + `is_marking_set_allowed` is pure set containment against the GUC — it never consults + `marking_definitions`. So a *stale, larger* cached clearance grants access that the current data no + longer justifies. Verified: + + ``` + row marking_ids = {m_green,m_deleted} + clearance = m_green,m_warm -> is_marking_set_allowed = false row hidden + clearance = m_green,m_warm,m_deleted -> is_marking_set_allowed = true row VISIBLE + ``` + + Every **reduction** of a clearance must evict: user removed from a group, marking unassigned from a + group, group deleted, marking definition archived/deleted, marking order lowered. During the window, + users with warm caches see rows that users with cold caches do not. This applies under **every** schema + option in §3.2 — it is a property of the GUC channel, not of the join table. + - *Reusable under Options A/B — the one item that survives a fallback.* + + **Delivered** + + | Artefact | Note | + |---|---| + | `MarkingCtx` (`openaev-model`) | sealed `None` / `Restricted` / `All`, shaped like `TxCtx`; `all()` throws on `toGuc()` — an unresolved intention must not reach the channel | + | `MarkingScopeResolver` | pure function, highest-order-per-type then expand downward; mirrors `TenantScopeResolver` | + | `MarkingClearanceCacheManager` | `@AllowRawJdbc`; `findClearance` + `evict` / `evictForUser` / `evictForUsers` / `evictAll` | + | eviction wiring | `TenantGroupService` + `PlatformGroupService` (`updateGroupUsers`, `delete`), `MarkingDefinitionService` (`update`, `delete`), and the assign endpoint from 2.4 | + + **Three corrections to the sketch above, each found by checking rather than assuming** + + - 🔴 **`evict(userId, tenantId)` was the wrong API for membership changes.** `Group` implements + `DualScopeBase`, so a *platform* group (`tenant_id IS NULL`) grants markings into many tenants at + once, and `users_groups` carries no tenant of its own. Evicting one tenant leaves the others + stale — fail-open, the exact case eviction exists to prevent. `evictForUser(userId)` walks every + tenant the user belongs to. `evict` also drops **both** bypass variants: making the caller name + the right one lets a stale *larger* entry survive under the other key. + - 🔴 **Asset marking updates need NO eviction** — this was in the list above and is wrong. + `is_marking_set_allowed(row_marking_ids)` takes the row's array as a function **argument**; only + the *clearance* lives in the GUC, and the row is re-read on every query. An evict on asset save + would be a no-op that *looks* like protection, which is worse than none: the next reader assumes + coverage. The reductions that genuinely need eviction are the ones that shrink a **clearance** — + group membership, group deletion, grant removal, definition delete, **order lowered**. + - `MarkingDefinitionService.update` therefore calls `evictAll()`, not a targeted evict: `order` and + `type` are resolver *inputs*, not labels. Raising a marking's order pushes it above clearances + that previously covered it, and the affected set is "everyone holding a grant of this type". + +**2.3 — marking scope written on both transaction paths** — ✅ **DONE** — set +`app.current_markings` next to `app.current_tenants` on **both** paths. The GUC is written but nothing reads +it until step 3. *(deps: 2.2)* + - **HTTP path** — no REST signature changes: the aspect derives the `MarkingCtx` itself (§4.1.1). + - **Background path** — `TenantScopedTransaction` sets the marking GUC in the same `setScope` call that + sets the tenant one, defaulting to all markings of the tenant(s) in scope. + - 🔴 **This is the step that makes activation safe for existing jobs.** Skipping the background half does + not fail a test — it makes every collector, executor and the ES sync silently read a subset once step 3 + lands. + + **Delivered** + + | Artefact | Note | + |---|---| + | `MarkingScopeSupplier` (`openaev-model`) | the seam — `openaev-model` has no dependency on `openaev-api` | + | `HttpMarkingScopeSupplier` (`openaev-api`) | derives from the principal + clearance cache; unions per-tenant clearances for a multi-tenant scope | + | `TenantScopeTransactionAspect` | writes both GUCs; `markingScopeFor()` fail-closed | + | `TenantScopedTransaction.systemClearance(TxCtx)` | resolves `all()` into the tenants' explicit marking ids | + | `TenantScopedTransactionMarkingScopeTest` (6, real DB) | mutation-checked: stubbing the clearance fails 2 | + + **The module-direction fork, and why the seam won.** The aspect lives in `openaev-model`; clearance + derivation needs `UserService` and the clearance cache, both in `openaev-api`. Three options: a seam + interface, a second aspect in `openaev-api`, or resolving in `TxCtxArgumentResolver`. The seam was + chosen because the invariant worth protecting is that **one** component owns "what scope may this + transaction have": a second aspect makes ordering load-bearing, and the argument resolver splits + scope arrival across two routes. `ObjectProvider` keeps the supplier optional so model-only slices + still start. + + **Tenant is passed, clearance is derived.** A caller legitimately chooses which tenant to act in; + nobody chooses their own clearance. A marking REST parameter would be a forgettable security + boundary. This is why there is no `X-Markings` header and never should be. + + 🔴 **The asymmetry that is easiest to misread.** `TxCtx.Missing` → zero rows. `MarkingCtx.None` → + **unmarked rows still visible** (the empty set is contained in the empty set). Fail-closed for + marking means "see less", never "see nothing" and never "see more". + + ⚠️ **Known blast radius for step 3**: a `@Transactional` method with **no `TxCtx` parameter** writes + *neither* GUC. Once a table is marking-active, such a transaction sees only unmarked rows — a silent + partial narrowing, not an error. Documented in the aspect javadoc; step 3's inventory must account + for it. + +**2.4 — `groups_markings` write path** — ✅ **DONE** — the assign endpoint, deferred from 2.1 and again +from 2.2. *(deps: 2.2)* + +> **Why this became a gate on step 3 rather than a step-5 nicety.** Until it existed, nothing wrote +> `groups_markings`, so *every* user resolved to `MarkingCtx.none()` and the clearance path was proven +> only against a stubbed `JdbcTemplate`. Step 3's DoD is "user cleared `TLP:GREEN` cannot read a +> `TLP:RED` asset" — undemonstrable with an empty grant table. + + | Artefact | Note | + |---|---| + | `PUT /api/tenants/{t}/groups/{g}/markings` | replace-the-whole-set; empty list revokes | + | `Group.markings` `@ManyToMany` | the *read* path stays raw JDBC (OSIV/Hikari); the *write* path has no such constraint | + | `MarkingEscalationValidator` | design **Q7**, pulled forward from 3.3 — it was needed here first | + | `TenantGroupMarkingsApiTest` (6) | the three manual flows end-to-end; eviction mutation-checked | + | `MarkingEscalationValidatorTest` (6) | incl. "higher implies lower is allowed" | + | [`demo/demo.sh`](./demo/demo.sh) | executable end-to-end demo: 9 assertions, mutation-checked, 7 UI checkpoints | + | [`demo/group-markings.sh`](./demo/group-markings.sh) | set what a group grants — the *clearance* side | + | [`demo/mark-asset.sh`](./demo/mark-asset.sh) | set what an asset carries — the side clearance is tested against | + + **Escalation is checked against the resolved clearance, not the raw grants** — so a user holding + `TLP:AMBER` *may* grant `TLP:GREEN`. They can already read every GREEN row, so granting it discloses + nothing they could not disclose otherwise; forbidding it would be annoying rather than safer. + + 🔴 **Finding: tenant isolation cannot be the only guard on a cross-tenant assignment.** The statement + inspector rewrites *queries* — it cannot filter a read that is never **issued**, and an entity already + in the persistence context is served from Hibernate's first-level cache. The independent guarantee is + the escalation guard: a clearance is per tenant, so nobody holds another tenant's marking. **The two + are not redundant, and this generalises to every v2 table** — it belongs in the step-4 skill. + + **Testing note carried into step 3**: the clearance read is raw JDBC and joins the test transaction, + but Hibernate has not flushed. An isolation test must `entityManager.flush()` before asserting, or it + measures the flush rather than the feature. + + **Deliberately still deferred to 5.6**: the `ASSIGN_MARKING` capability chain (Q8) — the endpoint + currently reuses the group's own `WRITE` control — and the platform-group equivalent, since a platform + group granting tenant markings is a cross-tenant question the PoC should not answer by accident. + +### Step 3 — PoC activation on `assets`, the **first** marking-enabled table *(deps: 1.4, 2.3)* + +`assets` is chosen first deliberately: it is the largest and most-joined of the three (§3.1 — Endpoint and +Security Platform share it), so it is the honest test of both the fail-closed blast radius (§5.1) and the +predicate cost (§5.5). If marking survives `assets`, the remaining tables are formalities. Per **Q10** it +does **not** need to be on tenant v2 first. + +**3.1** — Migration: `ALTER TABLE assets ADD COLUMN marking_ids text[]` + GIN index (§3.3); map it on `Asset` +with `@Type(StringArrayType.class)`, reusing the pattern already used by `asset_ips` on the same entity. + - *Fallback shape (if 1.4 failed): `assets_markings` join table + `ON DELETE CASCADE` both sides + + `@ManyToMany` + the `marking_usage` view.* + +**3.2** — Activate `openaev.marking.active-tables=assets`. + - **DoD**: isolation tests — user cleared `TLP:GREEN` cannot read/search a `TLP:RED` endpoint (404, not + 403); unmarked endpoint visible to all; **multi-marking AND semantics** (`TLP:GREEN` + `PAP:RED` hidden + from a TLP-only-cleared user); two users in different groups see different subsets; a multi-group user + gets the highest clearance; tenant + marking compose correctly. + +**3.3** — Wire `MarkingEscalationValidator` (already built in **2.4**) into the asset write paths, plus the +declassification audit event (§4.3). + - **Reduced scope**: the validator and its tests exist; what is missing is the *asset-side* wiring — + setting `marking_ids` on an asset must go through the same "you may not assign what you do not hold" + check the group endpoint already uses — and the audit event. + - 🔴 **Plus the invariant the group path does not need**: self-lockout. Marking an asset above your own + clearance makes it invisible **to you**, immediately. Decide explicitly whether to forbid it or allow + it with a warning; do not leave it to emerge. + - **DoD**: unit + API tests for — 403 on over-clearance assignment; the self-lockout invariant; an audit + event on every removal/downgrade and on nothing else. *(The capability-based layers of §4.3 land with + 5.6; until then the clearance check is the only guard.)* + +### Step 4 — Capture the procedure as an AI skill, then prove it *(deps: 3)* + +Step 3 is the only time anyone will have the whole activation procedure in their head. Capture it +immediately, the way `activate-tenant-table` captured the tenant equivalent. + +**4.1 — Write `.github/skills/activate-marking-table/SKILL.md`**, mirroring the phase structure of +`activate-tenant-table`: + +| Phase | Content | +|---|---| +| **0 — Eligibility gate** | Table is not on the **clearance-resolution path** (`groups`, `users_groups`, `groups_markings` — filtering these makes the resolver depend on a clearance it is computing, failing closed for everyone; §3.3); no raw-JDBC **writers** (§5.7 — the FK no longer guards id validity) or raw-JDBC readers that would bypass the inspector. **No PK constraint** — Option 2 marks composite-PK and relationship tables unchanged. *(Under the Option 1 fallback this phase must additionally reject composite-PK tables.)* | +| **1 — Inventory: reads** | Every read path: repository methods, native `@Query` joins, ES/OpenSearch reads (§5.2 — these bypass the inspector entirely), background jobs (§5.3), raw-JDBC `@AllowRawJdbc` sites | +| **1b — Inventory: downstream** | Which tables hold an **FK to this table** *and* denormalise any of its content (e.g. `injects_expectations.asset_id` + `inject_expectation_name`), and which **native aggregates** read them. Marking propagates transitively along every write the system makes on a marked row, and each aggregate is a place where "filtered rows" and "one number" disagree (§5.8). Produce the inventory — do **not** activate those tables; the per-viewer-vs-system aggregate decision is deferred to the next epic. Far cheaper to trace while activating than afterwards | +| **2 — RED** | Write the isolation test first: unmarked-visible / in-clearance / out-of-clearance 404 / multi-marking AND | +| **3 — GREEN: migration** | `ALTER TABLE ADD COLUMN marking_ids text[]` + GIN index; map with `@Type(StringArrayType.class)`. One statement, no FK, no cascade — and **nothing global to regenerate**. *(Under the Option 1 fallback: `
_markings` + `ON DELETE CASCADE` both sides + reverse index + `@ManyToMany` + regenerate `marking_usage`.)* | +| **4 — Activate** | Add the table to `openaev.marking.active-tables` | +| **5 — Write guard** | Wire `MarkingEscalationValidator` into the table's write paths (§4.3) | +| **6 — Regression** | Full tenant suite + marking suite; measure the marking predicate against a pre-activation baseline (§5.5) | + + - **DoD**: the skill names the exact files, properties and test classes, and states its stop conditions. + Register it in `AGENTS.md`. + +**4.2 — Prove the skill and the "cheap to extend" claim** — run it on `asset_groups`, then +`secret_references`. + - **DoD**: the *only* Java change per table is the migration class + the `@ManyToMany` mapping. If any + repository or service read code needs editing, Option C's core promise is broken → stop and + re-evaluate. `secret_references` is the valuable one: it is already tenant-v2-active, so it proves + tenant and marking compose on a real table. + +### Step 5 — Out of PoC, required for go-live + +**5.1** ES/OpenSearch filtering (§5.2). **5.2** Background-job marking scope (§5.3). **5.3** Join-table +exposure review (§5.7). **5.7** Decide and implement derived-data propagation + the aggregate semantics (§5.8). **5.4** Frontend marking multi-select + bulk edit. **5.5** Performance +validation (§5.5). **5.6** **Un-skim Task 1 / Task 2**: the two capability chains (Q8), `@AccessControl` +on the marking CRUD, the assignment UX — and the separate decision on whether to marking-activate +`groups_markings` itself, now that the resolver's bootstrap order is settled. + +🔴 **Finding: reading marking definitions is gated too tightly — the Markings column renders empty +for every non-admin.** `MARKING_DEFINITION` READ/SEARCH is bundled into `ACCESS_TENANT_SETTINGS` +(`Capability.java:337-338`), which `Manager` does not hold. A user who can see assets therefore gets +**403 on `/marking-definitions/search`**, cannot resolve the ids in `asset_markings` into names and +colours, and sees an empty column. + +Markings are reference data: anyone who can see a marked row needs to read them. Tags — the closest +analogue — get this right, with their own `ACCESS_TAGS` in `CapabilityGroup.TAXONOMY`. **The fix is a +dedicated `ACCESS_MARKINGS` capability mirroring `ACCESS_TAGS`**, granted wherever `ACCESS_TAGS` is; +this belongs with 5.6's capability-chain work. Until then `demo/demo.sh` grants `ACCESS_TENANT_SETTINGS` +through a throwaway role. + +Two things make this worth writing down rather than just fixing. It is **not a leak** — the ids are +already in the `asset_markings` payload, and a row only reaches you if its markings are inside your +clearance; it is purely a resolution failure. And it **fails silently**: `ItemMarkings` drops +unresolvable ids deliberately, because there is no FK from `assets.marking_ids` to +`marking_definitions` and a dangling id must not blank the page. So a 403 on the lookup and a +genuinely unmarked asset look identical on screen — only the network tab tells them apart. +--- + +## 2. PoC definition of done + +The PoC is successful when **all** of the following hold: + +1. A `TLP:RED` endpoint is invisible (search + direct GET) to a user whose group is cleared `TLP:GREEN`, + **with no change to `EndpointRepository` or `EndpointService` read code**. *(step 3.2)* +2. An endpoint marked `TLP:GREEN` + `PAP:RED` is hidden from a user cleared `TLP:AMBER` only (AND + semantics). *(step 3.2)* +3. The full pre-existing tenant isolation test suite is green, unmodified. *(step 1.1 — the go/no-go gate)* +4. Onboarding `asset_groups` costs exactly one `ALTER TABLE ... ADD COLUMN marking_ids` + one + `@Type(StringArrayType.class)` mapping + one property entry. *(step 4.2)* +5. The activation procedure is reproducible by someone who did not do step 3, from the + `activate-marking-table` skill alone. *(step 4.1, demonstrated by 4.2)* +6. `assets` search latency is within an agreed budget of the pre-marking baseline. *(§5.5, step 5.5)* + +7. The `<@` predicate reproduces the §4.4 truth table on real rows, fails closed on every empty/null + input, and applies unchanged to a composite-PK fixture table. *(step 1.4 — the schema-shape gate)* + +**Earliest signal**: criterion 3 plus the step 1.3 hypothesis test validate the whole mechanism on a +fixture table, with the GUC set by hand — reachable without any Task 1 or Task 2 work at all, and the +point at which Option C is proven or abandoned. Criterion 7 then settles *which schema shape* it is built +on, still before any real table is touched. + +**Explicitly out of the PoC** (deferred to 5.6, per Q12): the marking capability chains, `@AccessControl` +on the marking CRUD, the assignment UX, and any decision about marking-activating `groups_markings` +itself. The PoC answers "does transparent marking isolation work?", not "is marking well governed?". diff --git a/brainstorming/marking/tech-design-option-c.md b/brainstorming/marking/tech-design-option-c.md new file mode 100644 index 00000000000..5a260386a92 --- /dev/null +++ b/brainstorming/marking/tech-design-option-c.md @@ -0,0 +1,487 @@ +# Option C — Marking isolation "à la" tenant v2 + +**Marking cardinality: many-to-many.** An entity carries **zero, one or many** markings +(STIX `object_marking_refs` semantics). This is the decision this document is built on, and it drives most +of the design below. *How* that set is stored physically is a separate, argued choice — see section 3. + +--- + +## 1. Goal + +Enforce marking-based visibility the **same way tenant isolation v2** is enforced today: **transparently**, at +the SQL level, driven by a per-transaction scope channel — so that onboarding a new table to marking is a +**configuration change plus one migration**, not a rewrite of every repository method or service layer. + +Target developer experience: + +```properties +# adding a table to marking isolation = 1 line + 1 migration +openaev.marking.active-tables=assets,asset_groups,secret_references +``` + +--- + +## 2. Why the tenant v2 mechanism is the right shape to reuse + +The v2 tenant stack has exactly the four properties marking needs: +* **Transparent** for the developer: `TenantStatementInspector` rewrites the SQL Hibernate emits => ✅ identical need for marking +* **Fail-closed** when the GUC is unset: `can_access_tenant()` returns false => ✅ identical need for marking +* **Per-request**: `TxCtxArgumentResolver` → `TenantScopeResolver` → `TxCtx` → `set_config('app.current_tenants', …, true)` => ✅ same shape (groups → markings) +* **Incrementally activatable**: `openaev.tenant.active-tables` allowlist, inert until a table is onboarded => ✅ same need + +### 2.1 The hard constraint that shapes the whole design + +Hibernate accepts **exactly one** `AvailableSettings.STATEMENT_INSPECTOR` +(`TenantFilteringConfig#tenantStatementInspectorCustomizer` installs it with `putIfAbsent`). + +> ⚠️ ⚠️ **Marking cannot be a second, independent inspector.** It must be folded into the existing rewrite as a +> second *scope dimension*, or it will silently displace tenant isolation. + +This is the single most important architectural consequence of choosing Option C. + +### 2.2 The design trick that keeps it cheap: resolve ordinality in Java, not in SQL + +The naive reading of Option C says: tenant is a *set-membership* check +(`row_tenant_id = ANY(app.current_tenants)`) while marking is an *ordinal* check +(`row_marking_order <= my_clearance`), so the generic mechanism must support two comparison styles. + +> 💡💡 **It does not have to.** Ordinality is collapsed **in Java**, by `MarkingScopeResolver`: take the +> **highest order granted per type**, then expand it back into every id of that type at or below it. What +> reaches the database is a flat set of ids, so the SQL predicate stays a plain containment test — +> `app.current_markings = "id1,id2,id3"` where e.g. `id1: TLP:CLEAR, id2: TLP:GREEN, id3: CUSTOM1:GREEN`. + +**What `<@` is for.** `<@` is Postgres's array operator for *"is contained by"*: `A <@ B` is true when **every** +element of `A` is also in `B`. The predicate `row.marking_ids <@ my_clearance` therefore reads *"is every +marking on this row inside my clearance?"*. With clearance `{green, amber}`: + +| Row's `marking_ids` | `<@ {green,amber}` | Visible? | Why | +|---|---|---|---| +| `{}` (unmarked) | true | ✅ | the empty set is contained in everything — unmarked rows are visible for free | +| `{green}` | true | ✅ | held | +| `{green, amber}` | true | ✅ | both held | +| `{green, red}` | **false** | ❌ | `red` is not held — **one miss denies the whole row** | +| `{red}` | false | ❌ | not held | + +That fourth row is the point: `<@` gives **AND semantics** for free — you need *all* of a row's markings, not +any of them. Using `&&` (overlap, "any") instead would make `{green, red}` visible to someone holding only +green: a leak. And with no clearance the right side is `{}`, so every marked row is denied while unmarked +rows still pass — fail-closed with no extra flag. + +Three details the implementation adds to that sentence: + +- **Per type, independently.** Types are separate scales: holding `TLP:RED` says nothing about `PAP`, and a + type the caller was granted nothing on contributes nothing (it does not silently grant that type's lowest + level). +- **Resolved per (user, tenant, bypass) and cached**, not recomputed on every request — + `MarkingClearanceCacheManager` caches it with a 5-minute TTL. Eviction is therefore a **correctness** + requirement: a stale clearance that is larger than current data fails **open**. +- **Bypass resolves to the whole tenant scale**, expanded into an explicit id list rather than a wildcard, so + no wildcard ever enters the GUC channel on the HTTP path. + +### 2.3 What many-to-many changes + +Tenant isolation compares a **scalar**: `t.tenant_id` against a list. Marking compares a **set** against a +set. That single difference — not the choice of schema — is what drives the rest of the design: + +``` +tenant : row's tenant_id ∈ my tenants membership +marking : row's marking set ⊆ my clearance set containment +``` + +**Flattening (§2.2) does not make marking scalar.** It removes *ordinality* from the clearance side, not +*cardinality* from the row side: a row can still carry `{TLP:GREEN, PAP:AMBER}` — two markings from two +scales — so there is no single value to compare. Both sides stay sets, which is why the predicate is `<@` +(containment) and not `= ANY(...)` (membership). What flattening buys is that the SQL never has to know +about `order` or `type`. + +## 3. Data model + +### 3.1 Choosing the many-to-many shape — two options, one decision + +Both options store the same thing (a set of markings per row). They differ only on **where** that set lives. + +| | Shape | Read predicate | +|---|---|---| +| **Option 1** | one join table per marked table (`assets_markings`, …) | correlated anti-join | +| **Option 2** | `marking_ids text[]` column on the marked table | local `<@` containment test | + +#### Option 1 — join table + +```sql +assets_markings(asset_id, marking_id) PK (asset_id, marking_id) +asset_groups_markings(asset_group_id, marking_id) PK (asset_group_id, marking_id) +secret_references_markings(secret_reference_id, marking_id) PK (secret_reference_id, marking_id) +``` + +| ✅ Pros | ❌ Cons | +|---|---| +| Real FKs both sides → `ON DELETE CASCADE`, no orphans possible | One migration per marked table | +| Composite PK is exactly the anti-join access path (good plans) | Cannot mark tables whose PK is composite (relationships) | +| Plain JPA `@ManyToMany` + `@JoinTable` | Cross-entity queries need a `UNION` | + +#### Option 2 — `marking_ids text[]` column *(chosen for the PoC)* + +```sql +ALTER TABLE assets ADD COLUMN marking_ids text[]; +CREATE INDEX assets_marking_ids_idx ON assets USING GIN (marking_ids); +``` + +```sql +-- the whole predicate, no join: +COALESCE(t.marking_ids, '{}') <@ COALESCE(string_to_array(current_setting('app.current_markings', true), ','), '{}') +``` + +`<@` is "is contained by", which **is** the AND semantics: *every* marking on the row must be in my +clearance. An unmarked row is `'{}'`, and `'{}' <@ anything` is true, so it stays visible for free; with no +clearance the right side is `'{}'` and any marked row is denied. + +| ✅ Pros | ❌ Cons | +|---|---| +| No join at all — same cost class as the tenant check | No referential integrity: a garbage id is accepted, a deleted definition leaves a dangling id | +| Works on relationships unchanged (composite PK irrelevant) | `<@` is rarely served by GIN — must be measured, not assumed | +| Onboarding = `ADD COLUMN` + index; markings die with the row | No per-marking audit trail (array mutation rewrites the column) | +| Proven pattern here (`assets.asset_ips` is already `text[]`) | Only viable as the **sole** store; alongside join tables it would need trigger-syncing | + +> **Do not "optimise" this into `NOT (marking_ids && :lacked)`.** The lacked set is *all markings minus +> mine*, so a definition created after the scope was resolved is absent from it and its rows become +> **visible** — fail-open. The `<@` held-set form fails closed. Take the correct form; the arrays are tiny. + +#### Decision: **Option 2** for the PoC + +Chosen because the read predicate is a local column test and it marks relationships unchanged. The price is +the lost FK, paid back with machinery the design already requires: + +- **Insert side is free.** The §4.3 write guard already loads each marking definition to answer *"do you hold + it?"*, so existence is verified as a by-product — a garbage id cannot pass the service layer. +- **Delete side is explicit.** See §3.2. + +### 3.2 Deletion without a cascade + +Option 2 has no FK, so nothing cascades. Three cases must be distinguished, and only one is a problem: + +| What is deleted | What happens to the markings | Needs work? | +|---|---|---| +| A **marked row** (asset, asset group, secret reference) | the array dies with the row | ❌ nothing — simpler than Option 1 | +| A **marking removed from a row** (declassification) | the id is dropped from that row's array | ❌ nothing beyond the write guard — see below | +| A **marking definition** | `groups_markings` grants cascade, but `marking_ids` arrays keep the dead id | ✅ scrub + cache eviction | + +**Removing a marking from an asset needs nothing extra**, and the reason is worth stating because it looks +like it should. The predicate is `is_marking_set_allowed(marking_ids)`: the row's array is a function +*argument*, re-read on every query, while only the *clearance* lives in the cached GUC. So +`AssetMarkingsService.updateAssetMarkings` deliberately does **not** evict the clearance cache — evicting on a +row write would be a no-op that *looks* like protection. Eviction belongs only where a **clearance shrinks** +(group membership, grant removal, definition delete, order lowered). + +Two consequences fall out for free: +- **Self-lockout is impossible.** The write guard enforces `requested ⊆ your clearance`, and a row is visible + iff `row_markings ⊆ clearance` — so you can always still read what you just marked. +- **Declassification is the only direction worth auditing.** Adding a marking narrows visibility; removing one + widens it, so removals are logged (`logDeclassification`). + +**Deleting a definition permanently hides data — this is a real bug, not untidiness.** Once the definition +row is gone, its id survives inside `marking_ids` arrays. The read predicate is pure set membership against +the GUC and never consults `marking_definitions`, so it cannot tell *"deleted"* from *"exists but you do not +hold it"* — both simply deny. And the deleted id can never re-enter anyone's clearance, because its grants +cascaded away with it. Result: **every row carrying that id becomes invisible to the entire platform, +permanently, with no error raised** — not even to an admin holding every marking that still exists. + +``` +clearance = m_green,m_warm + m_green (held) -> allowed + m_red (exists, not held) -> denied + m_deleted (orphan) -> denied <- indistinguishable from m_red +``` + +#### What the PoC code does today + +`MarkingDefinitionService.delete(id)` performs the hard delete and evicts every cached clearance. The +`marking_ids` scrub is **deliberately not there yet**: no table is marking-activated, so no array can hold +the id. The scrub lands with activation (step 3), generated from the same `MarkedTables` registry that drives +the inspector, so it cannot drift out of sync with the allowlist: + +```java +for (MarkedTable t : markedTables.all()) + jdbc.update("UPDATE " + t.table() + " SET marking_ids = array_remove(marking_ids, ?)", markingId); +``` + +**And this is the cost Option 2 pays for losing the FK.** `ON DELETE CASCADE` would have touched only the +rows that actually reference the marking, via an index. The scrub instead issues one `UPDATE` **per marked +table**, and each one is a **full-table scan and rewrite** of every row it touches — the array is a plain +column, so Postgres has no cheap way to find "rows containing this id" unless the GIN index is used, and it +must still rewrite each matching row. Cost therefore grows with the *size of every marked table*, not with +the number of rows carrying the marking. On `assets` — the largest and most-joined of the three — that is a +noticeable write burst inside the delete transaction. + +Three mitigations, in order of preference: + +1. **Do not hard-delete**: archive the definition instead. This works because the row still exists, so + the id in `marking_ids` is never dangling — but it only works under one **load-bearing condition**: the + clearance resolver must keep seeing archived definitions. Clearance is computed from + `marking_definitions` (the tenant scale) joined with `groups_markings` (the grants), so as long as the + archived row and its grants survive, whoever held it still holds it and the marked rows stay readable. + An archive that also strips the grants — or a resolver that filters on `archived = false` — reproduces + the hard-delete bug exactly: the id becomes unholdable and the rows vanish platform-wide. Archiving + changes the id from *unholdable* to *still holdable but no longer assignable*; that is the whole trick. +2. **Narrow the scan** with `WHERE marking_ids @> ARRAY[?]` so the GIN index can select candidate rows + (`@>` is the containment direction GIN serves well), instead of rewriting blindly. +3. **Move it off the request** — run the scrub asynchronously if hard delete is ever shipped for large + tenants, accepting a short window where the id is dangling. + +```mermaid +sequenceDiagram + actor A as Admin + participant API as MarkingDefinitionApi + participant SVC as MarkingDefinitionService + participant REPO as MarkingDefinitionRepository + participant PG as PostgreSQL + participant CACHE as MarkingClearanceCacheManager + + A->>API: DELETE /markings/{markingId} + API->>SVC: delete(markingId) + SVC->>REPO: findById + delete + REPO->>PG: DELETE FROM marking_definitions + PG-->>REPO: groups_markings grants cascade (FK) + Note right of PG: no cascade to marking_ids arrays
(no FK under Option 2) + SVC->>PG: UPDATE {marked table} SET marking_ids = array_remove(marking_ids, id) + Note right of SVC: not implemented yet — lands with activation (step 3)
one UPDATE per marked table = costly + SVC->>CACHE: evictAll() + Note right of CACHE: grants are gone, but derived
clearances are still cached + SVC-->>API: done + API-->>A: 204 No Content +``` + +The archive-rather-delete policy above is what avoids needing the scrub at all. + +### 3.3 Concrete schema + +```sql +-- Task 1 (prerequisite): marking definitions, tenant-scoped +marking_definitions( + marking_id varchar PK, + marking_type varchar NOT NULL, -- TLP, PAP, custom + marking_definition varchar NOT NULL, -- TLP:RED + marking_order int NOT NULL, -- 1..10, 10 = highest + marking_color varchar, + tenant_id varchar NOT NULL REFERENCES tenants, + UNIQUE (marking_definition, tenant_id) -- composite, per multi-tenancy conventions +) + +-- Task 2 (prerequisite): group clearance +groups_markings( + group_id varchar REFERENCES groups(group_id) ON DELETE CASCADE, + marking_id varchar REFERENCES marking_definitions(marking_id) ON DELETE CASCADE, + PRIMARY KEY (group_id, marking_id) +) + +-- Task 3 (this design): one marking-set column per marked entity table +ALTER TABLE assets ADD COLUMN marking_ids text[]; +ALTER TABLE asset_groups ADD COLUMN marking_ids text[]; +ALTER TABLE secret_references ADD COLUMN marking_ids text[]; + +CREATE INDEX idx_assets_marking_ids ON assets USING GIN (marking_ids); +-- GIN serves "what is marked X?" (marking_ids @> ARRAY['X']). +-- The read predicate is `<@`, which GIN rarely serves; step 1.4 measures whether the index +-- is worth keeping on the read path or exists purely for admin/impact queries. +``` + +**Convention (load-bearing)**: the column is named `marking_ids`, type `text[]`, on the marked table itself. +Its *presence* is what lets `MarkingFilteringConfig` derive `MarkedTable` from `information_schema` instead +of a hand-maintained mapping — exactly how tenant tables are derived from `tenant_id`. `MarkedTable` therefore +needs **no PK column, no join table, no FK column**, which is why composite-PK tables and relationships work +unchanged. + +**Tenant scoping**: `marking_ids` lives on the marked row, which is already tenant-scoped. There is no second +table to confine. + +#### `groups_markings` is a clearance **grant**, not a marking attachment + +| Relation | Question it answers | Role | +|---|---|---| +| `groups_markings(group_id, marking_id)` | *What can members of this group see?* | clearance **grant** — an input to authorization (Task 2) | +| `groups.marking_ids` | *Who is allowed to see this group?* | marking **attachment** — an output of authorization | + +**`groups_markings` stays a join table under Option 2.** It is read by the Java resolver (groups → markings → +ordinal expansion, §2.2), never by the SQL predicate, so denormalising it buys nothing on the hot path; and as +authorization data it wants real FKs, keeping a genuine `ON DELETE CASCADE`. + +Marking the `groups` table itself, if ever wanted, is just `ALTER TABLE groups ADD COLUMN marking_ids text[]` +— sitting **beside** `groups_markings`, not replacing it. + + +## 4. How to adjust Tenant API v2 to fit Marking + +### 4.1 Class diagram + +```mermaid +classDiagram + class ScopeDimension { + <> + +name() String + +appliesTo(table) boolean + +predicateFor(table, alias) String + } + + class TenantDimension { + -tables : ScopedTables + +predicateFor() String + } + + class MarkingDimension { + -tables : MarkedTables + +predicateFor() String + } + + class StatementInspector { + <> + +inspect + } + class ScopeStatementInspector { + -dimensions : List~ScopeDimension~ + +inspect(sql) String + -predicatesFor(table, alias) String + -rewriteUpdate() + } + + class ScopeFilteringConfig { + <> + +tenantDimension + +markingDimension + +scopeStatementInspector + } + + class ScopeCtx { + +tenants() TxCtx + +markings() MarkingCtx + } + + class MarkingCtx { + <> + +toGuc() String + } + + class MarkingScopeResolver { + +resolve(user) MarkingCtx + } + + class MarkingClearanceCache { + +visibleMarkingIds(userId) Set~String~ + +evictOnGroupOrDefinitionChange() + } + + class TenantScopeTransactionAspect { + <<@Aspect>> + +applyScope(JoinPoint) + } + + + ScopeFilteringConfig --> ScopeStatementInspector : factory + ScopeDimension <|.. TenantDimension + ScopeDimension <|.. MarkingDimension + StatementInspector <|-- ScopeStatementInspector + ScopeStatementInspector --> ScopeDimension + + TenantScopeTransactionAspect --> ScopeCtx + ScopeCtx --> MarkingCtx + MarkingScopeResolver --> MarkingClearanceCache + MarkingScopeResolver --> MarkingCtx + + note for ScopeStatementInspector "This is the class responsible for all SQL rewrite. \nHanding both dimention Tenant and Marking filtering at READ" + style ScopeStatementInspector fill:#fff59d,stroke:#b28900 + style ScopeDimension fill:#fff59d,stroke:#b28900 + style TenantDimension fill:#fff59d,stroke:#b28900 + style MarkingDimension fill:#fff59d,stroke:#b28900 + style TenantScopeTransactionAspect fill:#fff59d,stroke:#b28900 + style ScopeCtx fill:#fff59d,stroke:#b28900 + style MarkingCtx fill:#fff59d,stroke:#b28900 + style MarkingClearanceCache fill:#fff59d,stroke:#b28900 + style MarkingScopeResolver fill:#fff59d,stroke:#b28900 +``` + +The `ScopeDimension` interface is the whole generalization: the inspector stops knowing *what* a tenant or +a marking is and only asks each dimension for a boolean SQL predicate on a table alias. + +#### 4.1.1 What activating a table on marking requires — compared to tenant v2 + +Activating a table on tenant v2 costs the developer **two** things at every entry point: the method must be +`@Transactional`, **and** it must take a `TxCtx` parameter. Marking needs the first but **not** the second. + +| | tenant v2 | marking | +|---|---|---| +| `@Transactional` on the entry point | ✅ required | ✅ **required** | +| a `Ctx` parameter on the REST method | ✅ required (`TxCtx`) | ❌ **not required** | + +**Why `@Transactional` is still required.** The scope travels as a *transaction-local* Postgres setting +(`set_config(…, true)`). The aspect that writes it runs `@Before` a `@Transactional` method, i.e. *inside* +an already-open transaction. Outside a transaction there is nothing to attach the setting to, the GUC stays +unset, and every marked row is hidden — fail-closed, but silently. This is identical to tenant v2 and is not +negotiable. + +**Why no `MarkingCtx` parameter is needed.** `TxCtx` is a parameter because a tenant scope is a *caller +choice* wheereas a **clearance is not a choice**. The practical consequence: **activating `assets` on marking changes no controller signature.** + +#### 4.1.2 The two dimensions are independent, not layered + +`ScopeStatementInspector` holds a `List`. For each table it asks **every** dimension two +questions — *do you cover this table?* and if so *what is your predicate?* — and `AND`s whatever comes back. +No dimension knows the others exist. + +So the two allowlists, `openaev.tenant.active-tables` and `openaev.marking.active-tables`, are genuinely +independent, and all four combinations are legal: + +| tenant v2 | marking | Result | +|---|---|---| +| ✅ | ✅ | `can_access_tenant(t.tenant_id) AND is_marking_set_allowed(t.marking_ids)` | +| ✅ | ❌ | today's behaviour, unchanged | +| ❌ | ✅ | **marking alone** — the table keeps tenant v1 `@Filter`, or is not tenant-scoped at all | +| ❌ | ❌ | inert | + + +#### 4.1.3 Background jobs — worked example: `InjectsExecutionJob` + +Take the question directly: Quartz fires `InjectsExecutionJob.execute()`, it picks up an inject whose target +is a **marked asset**. Does the job see that asset? + +**There is no user, so there is no clearance to derive.** + +**The answer: the job runs at system clearance, assigned by the primitive.** +The job sees every asset of its tenant, exactly as it does today, and **activating `assets` is a no-op for it**. + +> ⚠️ [OUT OF SCOPE of this POC] **But the job also writes.** It creates `InjectExpectation` rows against those +> marked assets, and those rows are later read by real users on the HTTP path. Seeing every asset obliges it +> to **re-apply the marking on the way out** — an expectation naming a `TLP:RED` endpoint must itself be +> `TLP:RED`, or the job has laundered the marking through a table nobody thought to activate. This +> generalises, and it is bigger than it looks: marking propagates transitively along every +> write the system makes on a marked row. Elasticsearch is the other instance — the ES sync legitimately indexes every +> marked row, so the **index** must carry `marking_ids` and the query side must filter on it. Same for +> anything that emails, exports or renders a digest. + + +### 4.2 Sequence — read path (transparent) + +```mermaid +sequenceDiagram + actor U as User + participant API as Endpoint API + participant ARG as TxCtxArgumentResolver + participant MSR as MarkingScopeResolver + participant ASP as ScopeTransactionAspect + participant PG as Postgres + participant INS as ScopeStatementInspector + + U->>API: GET /api/endpoints/search + API->>ARG: resolve the TxCtx parameter (unchanged, tenant only) + ARG-->>API: TxCtx(tenants) + API->>ASP: @Transactional entered + ASP->>PG: set_config('app.current_tenants', …, true) + Note over ASP: no MarkingCtx argument ⇒ derive it (§4.1.1) + ASP->>MSR: resolve(principal, txCtx) + MSR->>MSR: groups → markings → max order per type
→ expand to all marking ids ≤ max + MSR-->>ASP: MarkingCtx(id1,id2,id3) + ASP->>PG: set_config('app.current_markings', …, true) + API->>PG: repository query (unchanged code) + Note over INS: Hibernate emits SQL + INS->>INS: rewrite: AND can_access_tenant(t.tenant_id)
AND is_marking_set_allowed(t.marking_ids) + INS->>PG: filtered SQL + PG-->>API: rows in tenant AND fully within clearance + API-->>U: 200 (over-clearance rows do not exist → 404 on direct GET) +``` diff --git a/brainstorming/marking/tech-design.md b/brainstorming/marking/tech-design.md new file mode 100644 index 00000000000..5d7d2297462 --- /dev/null +++ b/brainstorming/marking/tech-design.md @@ -0,0 +1,355 @@ +# Feature: Marking-based Access Control for Assets - Phase 1 + +**Issue**: #[number] (if applicable) +**Type**: Full Stack +**Estimation**: [S | M | L | XL] + +--- + +## 📋 Context + +### Problem to Solve +Capability: +OAEV has a way to grant access to resources using either Group/role/capabilitites. if a user is part of a group with an associated role that contain the capability to READ/WRITE/DELETE a resource, the user can perform those actions. +With the recent implementation of bypass and grant escalation prevention, a user can not grant access to a capability he has not access to. + +Grant: +A user can access a scenario/simulation/atomic testing either through a specific grant, or globally if their group has the ASSESSMENT capability — and having the ASSESSMENT capability overrides individual grants (i.e., gives full access regardless of what grants are or aren't set). +How Grants Work in OpenAEV +Grants in OpenAEV are the fine-grained, resource-level permission layer that sits on top of the global RBAC (Roles → Capabilities). While Roles/Capabilities control what a user can do platform-wide (e.g., "can create scenarios"), Grants control access to specific individual resources (e.g., "this one simulation"). +Grants are always managed at the group level — you don't grant access to an individual user directly, you grant it to a group, and members inherit it. + +**Problem: access does not scale, because it has to be granted one resource at a time.** + +Grants are per-resource. To let a team see thirty simulations, someone assigns thirty grants — and +assigns thirty more next month. The only alternative on offer is the `ASSESSMENT` capability, which +overrides grants entirely and gives access to *everything*. So the choice today is between +per-resource bookkeeping that nobody keeps up with, and all-or-nothing. + +Neither expresses what people actually mean, which is rarely "this user may see resource #4471". It is +almost always **"this is sensitive, and only people cleared for that level should see it"** — a +property of the resource, not a list of who may open it. + +**What we want: sensitivity as a label on the resource, and a clearance on the group.** + +A resource carries a marking; a group is granted a clearance. A user sees a resource when their +clearance covers the marking it carries. Nobody maintains a list. + +- Resource marked **TLP:RED**, my group is cleared **TLP:RED** → I see it. +- Resource marked **TLP:RED**, my group is cleared only **TLP:AMBER** (one level below) → I do not. + +Markings are **ordered** (1 to 10, highest last), and a clearance **expands downward**: cleared for +TLP:RED means cleared for RED and everything beneath it, so the same user also sees TLP:AMBER +resources. Only the resource's own marking is ever compared against the clearance. + +Two consequences worth stating up front, because the rest of this document depends on them: + +- **Unmarked means visible.** Adding markings changes nothing until something is actually marked, so + the feature ships without a backfill and is inert until first use. +- **A marking can only ever reduce visibility, never widen it.** Marking is a filter layered *on top + of* RBAC — it never grants access that capabilities and grants have already denied. + +## Design options + +### Scope (current epic) +- In scope: **Asset (Endpoint, Security Platform), Credential**. +- Out of scope for this epic: **Asset Group**, and **Scenario, Simulation, Atomic Testing** (next epic). + +### Use Cases +1. **Administrator with the right capability** manages group markings and group membership so users inherit effective marking access. +2. **User with the right capability** assigns/removes markings on Endpoint, Security Platform, and Credential. +3. **User with the right capability** assigns/updates/removes markings on assets via bulk edit. +4. **Standard user** can only view assets within their authorized marking scope. + +### Integration in Architecture +- Keep **Capabilities/Roles/Groups** as-is for action authorization. +- Add a **Marking level** attribute on markable assets: Endpoint, Security Platform, Credential. +- Add **group-level marking clearance** for each marking domain (for example `TLP`), inherited by users through groups. +- Effective read access for those asset types becomes: + - User has required capability for the asset type **and** + - Asset marking level is `<=` user effective clearance for that domain. +- Marking is an additional visibility guard, layered on top of existing RBAC/capability checks. + +### Existing design fit (RBAC + Capability/Role/Group) +- **RBAC remains the action layer**: "what user can do". +- **Grant/asset-access rules remain resource-scoping layer**: "which assets user may target". +- **Marking becomes sensitivity layer**: "which marked assets user can see". +- Final decision model: + 1. Capability check (action allowed?) + 2. Grant/global access check (asset in scope?) + 3. Marking clearance check (classification allowed?) +- A deny at any step denies access. + +### 💡Changes in the Domain model + +This is the data model the feature needs. It is the same whichever enforcement mechanism is chosen +below — what changes between the options is only **where the visibility check runs**, never what a +marking *is* or how a user comes to hold one. + +```mermaid +classDiagram + class MarkingDefinition["MarkingDefinition (task1)"] { + +id + +type + +definition + +order + +color + } + class Group { + +id + +roles + +users + } + class GroupMarking["GroupMarking (task2)"] { + +groupId + +markingId + } + class Endpoint["Endpoint (task3)"] + class SecurityPlatform["SecurityPlatform (task3)"] + class Credential["Credential (task3)"] + + Group "1" --> "*" GroupMarking + GroupMarking "*" --> "1" MarkingDefinition + Endpoint "*" --> "*" MarkingDefinition : object_marking_refs (task3) + SecurityPlatform "*" --> "*" MarkingDefinition : object_marking_refs (task3) + Credential "*" --> "*" MarkingDefinition : object_marking_refs (task3) +``` + +Two invariants follow from this model alone, and every option below must satisfy them: + +- A user's **clearance** is derived from their groups' markings, never assigned directly — the same + rule already used for roles and grants. +- An entity carries **zero or more** markings (STIX `object_marking_refs`), so a user can view it only + if the user's allowed markings include **all** markings attached to that entity; this is not a + single-level comparison. + +### 🔓 RBAC + different options for Marking filtering + +RBAC is the first gate. Marking enforcement happens only after RBAC allows the call. + +```mermaid +sequenceDiagram + actor U as User + participant API as API method (@AccessControl) + participant RBAC as AccessControlAspect + participant TX as Transaction interceptor + participant SCOPE as TenantScopeTransactionAspect + participant SVC as Service + participant DB as Repository + SQL + participant SI as ScopeStatementInspector + + U->>API: request + API->>RBAC: @Before access check + RBAC-->>API: authorized + API->>SVC: call service + Note right of SVC: Option A: filter at service layer + SVC->>TX: enter @Transactional + TX->>SCOPE: tx is open, set app.current_tenants + app.current_markings + SCOPE-->>TX: scope set + TX->>DB: execute query + Note right of DB: Option B: filter at repository layer explicitly + DB->>SI: SQL rewrite hook + Note right of SI: Option C: filter at SQL rewrite implicitly + SI-->>DB: tenant/marking predicates injected (Option C) + DB-->>SVC: rows + SVC-->>API: result + API-->>U: 200 OK +``` + +**AOP order to remember** +- `@AccessControl` (`AccessControlAspect`) runs at API method entry and can stop the call early with + 403 (no transaction or query work when denied). +- `@Transactional` opens the transaction around service/repository work. +- `TenantScopeTransactionAspect` then writes transaction-local scope (`set_config(..., true)`) inside + that open transaction. +- `ScopeStatementInspector` applies when Hibernate emits SQL, so filtering is enforced at query time. + +### Implementation options (with pros/cons) + +1. **Option A — Service-layer marking filter with shared `MarkingAccessService`** — *evaluated, not chosen* + - **How**: Keep repositories mostly unchanged; apply marking visibility in service methods for Endpoint, Security Platform, Credential. Reuse one shared resolver for effective clearance. + - **Pros**: Fastest delivery for this epic, explicit logic, easy to test, low migration risk. + - **Cons**: Requires discipline to call filter in every read path; potential duplication if not centralized. + + - **What it looks like**: one read-filter service (`MarkingAccessService`) plus one write-side + marking CRUD service (`MarkingAssignmentService`), and a per-row check on the way out of the + service layer. + + ```mermaid + classDiagram + class MarkingAccessService { + +canView(user, entityMarkings) boolean + +effectiveClearance(user, markingType) int + +visibleMarkingIds(user) Set~String~ + } + class MarkingAssignmentService { + +assignMarkings(entityId, markingIds) + +bulkAssignMarkings(entityIds, markingIds) + +removeMarking(entityId, markingId) + } + MarkingAccessService --> Group + MarkingAccessService --> MarkingDefinition + MarkingAssignmentService --> MarkingDefinition + MarkingAssignmentService --> Endpoint + MarkingAssignmentService --> SecurityPlatform + MarkingAssignmentService --> Credential + ``` + + `MarkingAssignmentService` here is write-side (assign/remove), equivalent in intent to Option + C's `AssetMarkingsService`; it is not the read-filtering mechanism. + + Note what the service-side loop implies: the repository returns rows the user may not see, and correctness + depends on the service remembering to filter them. It also breaks pagination — a page of 50 + rows can come back with 30 after filtering. Both are the core objection to this option. + +2. **Option B — Repository-level filtering, explicitly wired per query** — *evaluated, not chosen* + - Both sub-options require explicitly wiring marking clearance into every repository/query path + that reads a marked asset type (e.g. adding a `findByMarkingClearance(...)`-style method, or + joining marking + group membership manually in each Specification/query). Nothing enforces + this automatically — it is on the developer to remember to add it to every new query. + + - **B1 — Explicit repository filtering (Specifications/queries join marking + group membership)** + - **How**: Push visibility checks into DB queries so only authorized assets are returned (join marking + group membership at query time, computed fresh on every read). + - **Pros**: Strong consistency when applied (harder to forget than a service-layer check once written), better performance on large result sets than filtering in-memory. + - **Cons**: Higher implementation complexity, query maintenance cost, more tenant/scoping edge cases, must be duplicated across every repository method. + + - **B2 — Explicit repository filtering + precomputed/cached effective clearance** + - **How**: Same explicit repository wiring as B1, but user effective marking clearance per domain is precomputed (cache/table) whenever group memberships/markings change, and reused by the repository queries instead of recomputing joins/subqueries every time. + - **Pros**: Best runtime efficiency for heavy search traffic, simpler read-time query shape than B1. + - **Cons**: Highest complexity of the explicit options, invalidation/sync risks for the cache, heavier rollout for the current epic. + + ```mermaid + sequenceDiagram + actor U as User + participant API as API method (@AccessControl) + participant RBAC as AccessControlAspect + participant SVC as Service + participant CLR as Clearance resolver/cache + participant REPO as Repository query (explicit) + participant DB as PostgreSQL + + U->>API: request + API->>RBAC: @Before access check + RBAC-->>API: authorized + API->>SVC: call service + SVC->>CLR: resolve effective markings + CLR-->>SVC: allowed marking ids + SVC->>REPO: search(criteria, allowedMarkings) + Note right of REPO: Option B: filter at repository layer explicitly + REPO->>DB: SQL with explicit marking predicate/join + DB-->>REPO: already-filtered rows + REPO-->>SVC: rows + SVC-->>API: result + API-->>U: 200 OK + ``` + + In Option B, correctness depends on every read path using a repository method that includes the + marking predicate. + +3. **Option C — Reuse the tenant-filter transparent rewrite mechanism ("à la" `TenantStatementInspector` / `can_access_tenant`)** — ✅ **chosen** + - **How**: Extend the existing statement-inspector rewrite (used today for `tenant_id` via `app.current_tenants` + `can_access_tenant(...)`) to also inject a marking predicate on marked tables, driven by a per-transaction session variable (`app.current_markings`) derived from the current user's effective group clearance. Because markings are many-to-many, the injected predicate is a correlated anti-join on the entity's markings join table. + - **Pros**: + - Fully transparent to developers — no per-repository/query changes needed (unlike B1/B2), + - consistent with the existing tenant isolation pattern, + - hard to forget (fail-closed by design like tenant filtering), + - single enforcement point, onboarding a new table costs one migration + one property entry. + - **Cons**: + - Higher upfront complexity (SQL rewrite logic, session variable management), couples marking enforcement to the Hibernate/JDBC layer, + - harder to reason about/debug than an explicit repository predicate, + - modifying the sole enforcement point of multi-tenancy v2 (regression risk), heavier testing surface (matches `TenantStatementInspector`'s own complexity). Does **not** cover Elasticsearch-served reads. + + ```mermaid + sequenceDiagram + participant C as Client + participant R as TxCtx resolver + participant H as "@Transactional handler" + participant A as TenantScopeTransactionAspect + participant M as HttpMarkingScopeSupplier + participant K as MarkingClearanceCacheManager + participant DB as PostgreSQL + C->>R: GET /api/tenants/t1/assets + R->>H: TxCtx = [t1] + H->>A: transaction opens + A->>M: resolve caller effective markings + M->>K: findClearance(user, t1) + K-->>M: marking ids + M-->>A: marking scope (ids) + A->>DB: set_config('app.current_tenants', 't1', true) + A->>DB: set_config('app.current_markings', 'm1,m2,...', true) + H->>DB: queries (rewritten with tenant + marking predicates) + DB-->>C: only rows in tenant scope and marking scope + ``` + + +### Making Options A / B generic and reusable as more entities are onboarded + +Options A and B are opt-in by construction: enforcement happens where a developer *remembers* to call it. +They can still be made significantly more reusable — the goal is to reduce the per-entity cost from +"write a marking predicate for every query" to "declare the entity as markable", and to make the +remaining human step **impossible to forget silently**. + +1. **A single `Markable` contract on the model side.** `interface Markable { Set getMarkings(); }` + implemented by `Asset` (covers Endpoint + Security Platform) and `CredentialSecretReference`. + Everything below keys off this interface instead of enumerating entity types. + +2. **One shared predicate, not one per repository.** A single + `MarkingSpecifications.visibleTo(user)` returning a reusable `Specification`. + Because markings are **many-to-many**, the predicate is a "no marking I don't hold" check — in JPA + Criteria a correlated `NOT EXISTS` subquery on the join, *not* a naive `join.get("id").in(clearedIds)` + (which would silently give OR/union semantics and leak). Adding an entity adds **zero** new predicate code. + +3. **Hook it into the pagination choke point.** Every paginated search endpoint funnels through + `PaginationUtils.buildPaginationJPA(...)` / `buildPaginationCriteriaBuilder(...)`, which already + composes `filterSpecifications.and(searchSpecifications)`. `.and(markingSpecification)` can be added + there once, applied automatically to any `Markable` entity class. This makes **Option B transparent for + the search/list paths**, which is the bulk of the read surface — the single highest-leverage change for + reusability. + +4. **A shared read guard for the non-paginated paths (Option A).** Single-entity `findById`, "related + objects" lookups and export paths do not go through pagination. Cover them with one `@MarkingFiltered` + AOP advice on service methods returning `Markable` (or `Collection` / `Page`) that + post-filters generically via the interface — one aspect, N entities, no per-service code. + +5. **A registry + a build-time guardrail.** A `MarkingActiveEntities` registry (the A/B analogue of + `openaev.marking.active-tables`) plus an **ArchUnit rule** that fails the build when a repository method + returning a registered `Markable` type is called from a service without a marking specification or + `@MarkingFiltered`. This mirrors the existing tenant ArchUnit guardrails and is the *only* mechanism that + makes A/B safe at scale. + +6. **One write guard for all of them.** `MarkingEscalationValidator.assertCanAssignMarking(user, markingId)`, + modeled on `PrivilegeEscalationValidator`, called from every write path regardless of entity type. + +**The honest trade-off**: items 1–4 make A/B *centralized*, and item 3 makes the search paths effectively +transparent — but enforcement remains **opt-in per query**. A new native `@Query`, a new custom repository +method, or a new join added by someone unaware of marking will silently return over-clearance rows. Item 5 +converts that from a silent leak into a build failure, which is as close to safe as A/B can get. Option C +is the only option where forgetting is *structurally impossible*, because the enforcement point is below +the code a developer writes. + +### Direction — Option C + +**Decided: Option C.** Recorded in [ADR-007](../../adr/ADR-007-Marking-based-access-control.md); +detailed design, risks and PoC plan in [tech-design-option-c.md](./tech-design-option-c.md). + +The deciding argument is not cost, and it is not performance — it is that **A and B are opt-in by +construction**. In both, enforcement happens where a developer remembers to put it, so a new native +`@Query`, a new custom repository method, or a join added by someone who has never heard of markings +returns over-clearance rows and nothing complains. + +Section +[Making Options A / B generic](#making-options-a--b-generic-and-reusable-as-more-entities-are-onboarded) +sets out how far that can be mitigated: a `Markable` contract, one shared specification, a hook in +the pagination choke point, and an ArchUnit rule that turns a forgotten filter into a build failure. +That is genuinely close to safe — but it is still a list of things that must not be forgotten. + +Option C is the only option where forgetting is **structurally impossible**, because the enforcement +point sits below the SQL a developer writes. That is the same reason multi-tenancy v2 works, and +marking is the second dimension on the same mechanism rather than a parallel invention. + +What was accepted in exchange, honestly: + +- **Slower to first delivery than Option A.** Accepted on the argument above. +- **Higher upfront complexity** — statement rewriting, a new SQL function, GUC and transaction-scope + management — and it means touching the single enforcement point of multi-tenancy v2, so the + existing tenant isolation suite has to stay green throughout. +- **Does not cover Elasticsearch-served reads.** Out of PoC scope diff --git a/brainstorming/marking/user-stories.md b/brainstorming/marking/user-stories.md new file mode 100644 index 00000000000..a71d4454b44 --- /dev/null +++ b/brainstorming/marking/user-stories.md @@ -0,0 +1,976 @@ +> # 🛑 THIS FILE IS NOT THE SOURCE OF TRUTH 🛑 +> +> ## The source of truth for these user stories is **Notion**. +> +> **EPIC — Marking based access control:** +> +> +> | Task | Notion ID | Title | +> |---|---|---| +> | Task 1 | **590** | Create & Manage marking definitions | +> | Task 2 | **591** | Assign markings to users | +> | Task 3 | **592** | Assign markings to assets | +> +> ⚠️ **What this file actually is:** a point-in-time *export* of the above, kept in the repository +> so the technical design documents next to it can quote acceptance criteria without sending the +> reader to another tool. It is a convenience copy and nothing more. +> +> ⚠️ **It is already known to be stale.** Task 2 appears **twice** below (lines ~317 and ~523), and +> the two copies are **not identical** — the second carries an "⚠️ Important Flags" section the +> first lacks. That is the drift you get from a manual copy, and it is exactly why this banner +> exists. +> +> ⚠️ **Do not edit acceptance criteria here.** Edits made in this file are invisible to PM and +> stakeholders, will not be reviewed, and will be silently overwritten by the next export. **Change +> Notion, then re-export.** +> +> ✅ If a statement here disagrees with Notion, **Notion wins** — treat the difference as a bug in +> this file, not as a decision. + +--- + +# Task 1 — Create & Manage + +## Properties + +- **Task ID:** 590 +- **Status:** Business Refinement needed +- **Status 1:** Not started +- **Verticals:** #3 Easy-to-Use & consistent platform +- **EPIC:** https://app.notion.com/p/2c58fce17f2a803081dcf80b5a591db9 +- **Sub-tasks:** + - US1 — Assign "Manage Marking Definitions" Capability to a Role + - US2 — Navigate to Marking Definition + - US3 — Create a Marking Definition + - US4 — Edit a Marking Definition + - US5 — Delete a Marking Definition + - US6 — Default TLP Markings are Pre-loaded on Platform Initialization ( nice to have ) + +## AI Summary + +- 📌 Develop a marking-definition feature (TLP only) to let admins grant “Manage marking definitions” capability and users create, edit, or delete TLP markings. +- 🚀 Provide default TLP markings (CLEAR, GREEN, AMBER, AMBER+STRICT, RED) and integrate them into the RBAC system for future role, group, and asset assignments. + +--- + +# 🎯 Business Context (👥 PM + Stakeholders) + +## Use Case & Business Goals + +OpenAEV is introducing a marking-based access control layer to improve segregation of duties and sensitive data isolation across the platform. Task 1 focuses on the foundation: enabling authorized users to create and manage marking definitions (e.g. TLP:RED, TLP:GREEN, PAP:AMBER), mirroring the proven model from OpenCTI. These markings will later be assigned to roles, groups, and assets in subsequent tasks. + +## 👤 Users & Personas + +- **User with "Manage marking definitions" capability** ⇒ creates, edits, deletes marking definitions +- **Administrator** ⇒ assigns the "Manage marking definitions" capability to roles + +# 🧠 WHAT DO WE WANT + +## 🧭 User Flow (mapped to user stories) + +### Flow A — Admin grants “Markings management” to users (RBAC) + +1. Admin opens **Roles & Permissions** and selects a role to edit. *(US1)* +2. Admin enables **Manage Marking Definitions** for that role and saves. *(US1)* +3. Admin assigns that role to the target users (or ensures they already have it). *(US1 — scope reminder)* +4. User logs in / refreshes permissions and can access **Marking Definitions**. *(US2)* + +### Flow B — Create a marking definition (severity + color) + +1. User with the capability opens the menu and navigates to **Marking Definitions**. *(US2)* +2. User clicks **Create marking**. *(US3)* +3. User fills in: + - **Name / label** (e.g., TLP:GREEN) + - **Severity / level** (e.g., Low/Medium/High or TLP/PAP level as defined) + - **Color** (used consistently across UI) +4. User saves and sees the new marking in the list and in its details. *(US3)* + +### Flow C — Maintain markings over time + +1. User opens an existing marking definition from the list. *(US2)* +2. User edits name / severity / color and saves. *(US4)* +3. If a marking should be removed, user deletes it (with any confirmation/warnings). *(US5)* + +## 🧩 Design Decision — Marking Types for OpenAEV + +### Context + +As part of the marking definitions feature, we evaluated whether OpenAEV should support both **TLP (Traffic Light Protocol)** and **PAP (Permissible Actions Protocol)** as marking types, in line with what ANSSI [https://www.cert.ssi.gouv.fr/csirt/politique-partage/](https://www.cert.ssi.gouv.fr/csirt/politique-partage/) reference. + +### Decision + +**OpenAEV will support TLP markings only** for this scope. + +### Rationale + +- **TLP** governs visibility — who can see and access an object (asset, simulation). This is the missing layer that markings introduce and is not covered by any existing mechanism. +- **PAP** governs permissible actions — what a user is allowed to do with an object once they have access. In OpenAEV, this is **already covered** by the existing capabilities model. +- Introducing PAP markings on top of this would be **redundant** and would create conflicting access control logic. so we stick to TLP. + +### Default TLP Markings + +On platform initialization, the following **5 default TLP markings** will be pre-loaded, consistent with OpenCTI and the TLP v2.0 standard: + +| NAME | ORDER | +|---|---:| +| TLP:CLEAR | 1 | +| TLP:GREEN | 2 | +| TLP:AMBER | 3 | +| TLP:AMBER+STRICT | 4 | +| TLP:RED | 5 | + +These are the same default markings used in OpenCTI, ensuring consistency across the Filigran platform ecosystem. + +## 📜 User Stories + +### User stories pages + +- US1 — Assign "Manage Marking Definitions" Capability to a Role +- US2 — Navigate to Marking Definition +- US3 — Create a Marking Definition +- US4 — Edit a Marking Definition +- US5 — Delete a Marking Definition +- US6 — Default TLP Markings are Pre-loaded on Platform Initialization ( nice to have ) + +# Sub-task: US1 — Assign "Manage Marking Definitions" Capability to a Role + +## Properties + +- **Task ID:** 596 +- **Status:** Business Refinement needed +- **Status 1:** Not started +- **Parent-task:** Task 1 — Create & Manage +- **Verticals:** #3 Easy-to-Use & consistent platform + +## AI Summary + +- 🎯 Assign “Manage marking definitions” and “Assign marking” capabilities to roles for independent access control. +- 🔧 Ensure capability cascades (Access → Manage/Assign → Delete) and respects Bypass overrides. + +## User story + +**As an** administrator, +**I want** to assign the **"Manage marking definitions"** and/or **"Assign marking"** capabilities to a role, +**So that** users with that role can access and manage marking definitions, and/or assign/remove markings on groups and assets, independently of each other. + +## Acceptance criteria + +- **AC1** — Given I am editing a role in Settings → Security → Roles, When I view the capability list, Then **"Marking"** appears as a new top-level capability group, containing two independently assignable chains — **"Marking definitions"** (Access → Manage → Delete) and **"Assign marking"** (Access → Assign → Delete) — neither nested under "Manage credentials" or any other existing category. + +- **AC2** — Given I enable **"Manage marking definitions"** for a role and save, When a user assigned to that role refreshes their session (re-login or permission refresh), Then they gain access to the **Marking Definitions** entry under Settings → Security. + +- **AC2b** — Given I enable **"Assign marking"** for a role and save, When a user refreshes their session, Then they can assign/remove markings on Groups and Assets (Manage Markings action becomes available), independently of whether "Manage marking definitions" is also granted. + +- **AC3** — Given a user's role has neither "Manage marking definitions" nor "Assign marking" enabled, When they navigate to Settings → Security, Then the **Marking Definitions** entry is **hidden** from the menu (not merely disabled), and marking-assignment actions on Groups/Assets are hidden as well. + +- **AC4** — Given a user's role has the **Bypass** capability enabled, When they navigate to Settings → Security or to a Group/Asset, Then they can access Marking Definitions and assign/remove markings regardless of whether "Manage marking definitions" or "Assign marking" is explicitly granted. + +- **AC5** — Given each capability sits in a strict L1→L2→L3 chain (Access → Manage/Assign → Delete), When an admin enables **Manage marking definitions** or **Delete marking definitions**, Then **Access marking definitions** is automatically enabled as its parent — and symmetrically, enabling **Assign marking** or **Delete marking assignment** auto-enables **Access marking assignment**. *(Confirmed 2026-08-11: cascade behavior verified in the mock-up; both chains behave identically to existing capability categories.)* + +## Low-fidelity mockup + +--- + +# Sub-task: US2 — Navigate to Marking Definition + +## Properties + +- **Task ID:** 599 +- **Status:** Business Refinement needed +- **Status 1:** Not started +- **Parent-task:** Task 1 — Create & Manage +- **Verticals:** #3 Easy-to-Use & consistent platform + +## AI Summary + +- 📌 Navigate to **Settings > Security > Marking Definitions** to manage all marking definitions in one place. +- ✅ Users with the “Manage marking definitions” capability see the list, can create, search, and filter markings; others receive an access-denied response. + +## User story + +> As a user with the "Manage marking definitions" capability, I want to navigate to Settings > Security > Marking Definitions so that I can manage all markings in one dedicated place. + +## Acceptance criteria + +- **AC1** — Given I am logged in as a user with the "Manage marking definitions" capability, When I navigate to Settings > Security, Then I see a "Marking Definitions" entry in the left navigation menu. +- **AC2** — Given I click on "Marking Definitions", When the page loads, Then I see a list of existing markings with columns: Type, Definition, Color, Order, Creation date. +- **AC3** — Given I am on the Marking Definitions page, When the page loads, Then a "Create Marking Definition" button is visible. +- **AC4** — Given I am logged in as a user without the "Manage marking definitions" capability, When I try to access Settings > Security > Marking Definitions, Then the page is not accessible or I see an access denied message. +- **AC5** — Given I am on the Marking Definitions page, When I use the search field, Then I can search existing marking definitions by Type, Definition, Color, Order, and Creation date. +- **AC6** — Given I am on the Marking Definitions page, When I apply filters, Then I can filter the list by Type, Definition, Color, Order, and Creation date. + +--- + +# Sub-task: US3 — Create a Marking Definition + +## Properties + +- **Task ID:** 595 +- **Status:** Business Refinement needed +- **Status 1:** Not started +- **Parent-task:** Task 1 — Create & Manage + +## AI Summary + +- 📌 Create a new marking definition via a modal with required fields (Type, Definition, Color, Order). +- ✅ Validate required inputs and save the marking, making it instantly visible in the list. + +## User story + +> As a user with the "Manage marking definitions" capability, I want to create a new marking definition so that I can classify assets and payloads with the appropriate sensitivity level. + +## Acceptance criteria + +- **AC1** — Given I am on the Marking Definitions page, When I click "Create Marking Definition", Then a creation modal opens with the following fields mirroring OpenCTI's model: + - Type (required, e.g. TLP / PAP / custom) + - Definition (required, e.g. TLP:RED) + - Color (color picker, e.g. #cc0000) + - Order (required numeric, e.g. TLP:CLEAR=1, TLP:GREEN=2, TLP:AMBER=3, TLP:RED=4) + +- **AC2** — Given the creation modal is open, When I fill in Type, Definition, Color and Order and click "Create", Then the new marking is saved and immediately visible in the list. + +- **AC3** — Given the creation modal is open, When I submit the form without filling in Type, Definition or Order, Then a validation error is shown on the missing required fields and the form cannot be submitted. + +--- + +# Sub-task: US4 — Edit a Marking Definition + +## Properties + +- **Task ID:** 597 +- **Status:** Business Refinement needed +- **Status 1:** Not started +- **Parent-task:** Task 1 — Create & Manage + +## AI Summary + +- ✏️ Edit existing marking definitions directly from the list (via the action menu). +- 📄 Pre-filled edit form with current values, allowing quick updates and immediate save reflection. + +## User story + +> As a user with the "Manage marking definitions" capability, I want to edit an existing marking definition so that I can update its details if needed. + +## Acceptance criteria + +- **AC1** — Given I am on the Marking Definitions page, When I click the action menu (⋮) on a marking row, Then I see an "Edit" option. +- **AC2** — Given I click "Edit" on a marking, When the edit form opens, Then all existing values are pre-filled. +- **AC3** — Given I update one or more fields and click "Save", When the save is confirmed, Then the changes are reflected immediately in the list. + +--- + +# Sub-task: US5 — Delete a Marking Definition + +## Properties + +- **Task ID:** 598 +- **Status:** Business Refinement needed +- **Status 1:** Not started +- **Parent-task:** Task 1 — Create & Manage + +## AI Summary + +- 🗂️ Delete unwanted marking definitions to keep the list clean. +- ✅ Confirm deletion and warn if the marking is in use. + +## User story + +> As a user with the "Manage marking definitions" capability, I want to delete a marking definition that is no longer relevant so that I can keep the list clean. + +## Acceptance criteria + +- **AC1** — Given I am on the Marking Definitions page, When I click the action menu (⋮) on a marking row, Then I see a "Delete" option. +- **AC2** — Given I click "Delete" on a marking, When the confirmation dialog appears, Then I must confirm before the deletion is executed. +- **AC3** — Given the marking is currently assigned to an asset, payload, or group, When I attempt to delete it, Then the system warns me that this marking is in use (block vs. warn — to be decided). + +--- + +# Sub-task: US6 — Default TLP Markings are Pre-loaded on Platform Initialization ( nice to have ) + +## Properties + +- **Task ID:** 628 +- **Status:** +- **Status 1:** Not started +- **Parent-task:** Task 1 — Create & Manage + +## AI Summary + +- 📥 Auto-load the five standard TLP markings (CLEAR, GREEN, AMBER, AMBER+STRICT, RED) during platform initialization. +- 🛠️ Enables administrators and users to apply TLP classifications instantly without manual setup. + +## User story + +> As a platform administrator, I want the standard TLP marking definitions to be automatically available when the platform is initialized, so that users can immediately apply markings without requiring manual setup. + +## Acceptance criteria + +- **AC1 — Pre-loaded markings** — Given the platform has just been initialized, When I navigate to Settings > Marking Definitions, Then the following 5 TLP markings are already present and visible: + +| Name | Type | Order | +|---|---|---:| +| TLP:CLEAR | TLP | 1 | +| TLP:GREEN | TLP | 2 | +| TLP:AMBER | TLP | 3 | +| TLP:AMBER+STRICT | TLP | 4 | +| TLP:RED | TLP | 5 | + +# Task 2 — Assign Markings to users + +## Properties + +- **Task ID:** 591 +- **Status:** Business Refinement needed +- **Status 1:** Not started +- **Verticals:** #3 Easy-to-Use & consistent platform +- **EPIC:** https://app.notion.com/p/2c58fce17f2a803081dcf80b5a591db9 +- **Sub-tasks:** + - US1 — View markings assigned to a group + - US2 — Assign a marking to a group + - US3 — Remove a marking from a group + - US4 — Access control based on group marking . + - US5 — Highest marking applies when user belongs to multiple groups + +## AI Summary + +- 📌 Assign marking definitions to groups in OpenAEV, letting users inherit the highest marking from their groups. +- 🛠️ Admins can view, add, edit, or remove group markings and manage group membership to control object access. + + +# 🎯 Business Context (👥 PM + Stakeholders) + +## Use Case & Business Goals + +Task 2 focuses on assigning marking definitions to groups in OpenAEV, following the same model as OpenCTI. A group can be assigned one or more markings — members of that group will only see and interact with objects whose marking level matches or is below their group's assigned markings. When a user belongs to multiple groups, the highest marking applies. + +## 👤 Users & Personas + +- **Administrator** ⇒ assigns markings to groups, manages group membership +- **User with the right capability** ⇒ views and interacts with objects based on their group's marking level + +# 🧠 WHAT DO WE WANT (Business Refinement) + +## 🧭 User Flow (mapped to user stories) + +### Preconditions (dependency on Task 1) + +- A marking definition exists (created/managed in Task 1). +- The admin has granted the appropriate permissions so the Administrator can manage group markings. + +### Flow A — View current group markings + +1. Administrator opens a **Group** and navigates to its **Markings** section. *(US1)* +2. Administrator sees the list of markings currently assigned to the group. *(US1)* + +### Flow B — Assign markings to a group + +1. Administrator opens a group and clicks **Edit** (or **Manage markings**). *(US2)* +2. Administrator selects one or more markings and saves. *(US2)* +3. Administrator sees the updated markings displayed on the group. *(US1)* + +### Flow C — Remove a marking from a group + +1. Administrator opens the group’s markings and removes a marking, then saves. *(US3)* +2. The marking is no longer listed on the group. *(US1)* + +### Flow D — Add users to groups (so they inherit markings) + +1. Administrator opens a group and goes to **Members**. +2. Administrator adds/removes users in the group and saves. +3. Users’ effective marking level updates (highest marking across their groups). *(US5)* +4. Access is enforced based on group markings. *(US4)* + +## 📜 User Stories + +### User stories pages + +- US1 — View markings assigned to a group +- US2 — Assign a marking to a group +- US3 — Remove a marking from a group +- US4 — Access control based on group marking . +- US5 — Highest marking applies when user belongs to multiple groups + + +# Sub-task: US1 — View markings assigned to a group + +## Properties + +- **Task ID:** 602 +- **Status:** Business Refinement needed +- **Status 1:** Not started +- **Parent-task:** Task 2 — Assign Markings to users +- **Verticals:** #3 Easy-to-Use & consistent platform + +## AI Summary + +- 📋 View the list of markings assigned to a group on the group detail page. +- ✅ Shows each marking’s name and color; displays an empty state if no markings are assigned. + +## User story + +> *As a user with the right capability, I want to view the list of markings assigned to a group, so that I can understand what marking levels are accessible to members of that group.* + +## Acceptance criteria + +- **AC1** — Given I am on the group detail page, When I open a group, Then I see a "Markings" section listing all markings currently assigned to that group. +- **AC2** — Given no markings are assigned to the group, When I open the Markings section, Then I see an empty state. +- **AC3** — Given markings are assigned, When I view the Markings section, Then each marking is displayed with its name and color. + +--- + +# Sub-task: US2 — Assign a marking to a group + +## Properties + +- **Task ID:** 601 +- **Status:** Business Refinement needed +- **Status 1:** Not started +- **Parent-task:** Task 2 — Assign Markings to users +- **Verticals:** #3 Easy-to-Use & consistent platform + +## AI Summary + +- 📋 **User story:** Enable users with proper permissions to assign a marking to a group, allowing group members to access objects tagged with that marking. +- ✅ **Acceptance criteria:** Add a marking via a selector on the group detail page, ensure selected markings are saved, prevent already-assigned markings from appearing again, and display the new marking in the Markings list. + +## User story + +> *As a user with the right capability, I want to assign a marking to a group, so that members of that group can access objects with that marking.* + +## Acceptance criteria + +- **AC1** — Given I am on the group detail page, When I click to add a marking, Then a selector opens showing available markings. +- **AC2** — Given I select a marking from the list, When I confirm, Then the marking is added to the group. +- **AC3** — Given a marking is already assigned to the group, When I open the selector, Then that marking does not appear as an option. +- **AC4** — Given the assignment is saved, When I view the Markings section, Then the new marking appears in the list. + +--- + +# Sub-task: US3 — Remove a marking from a group + +## Properties + +- **Task ID:** 604 +- **Status:** Business Refinement needed +- **Status 1:** Not started +- **Parent-task:** Task 2 — Assign Markings to users +- **Verticals:** #3 Easy-to-Use & consistent platform + +## AI Summary + +- 📌 User story: Enable users with proper rights to remove a marking from a group, restricting marking levels for group members. +- ✅ Acceptance criteria: Show remove action per marking, confirm before deletion, and ensure the marking disappears after confirmation. + +## User story + +> *As a user with the right capability, I want to remove a marking from a group, so that I can restrict what marking levels are accessible to members of that group.* + +## Acceptance criteria + +- **AC1** — Given I am on the group detail page, When I view the Markings section, Then I see a remove action next to each assigned marking. +- **AC2** — Given I click remove on a marking, When the action is triggered, Then a confirmation is shown before deletion. +- **AC3** — Given I confirm the removal, When it is saved, Then the marking no longer appears in the group's Markings section. + +--- + +# Sub-task: US4 — Access control based on group marking . + +## Properties + +- **Task ID:** 603 +- **Status:** Business Refinement needed +- **Status 1:** Not started +- **Parent-task:** Task 2 — Assign Markings to users +- **Verticals:** #3 Easy-to-Use & consistent platform + +## AI Summary + +- 🎯 Develop group-based access control so users only see groups with markings matching or below their own. +- ✅ Ensure the feature restricts visibility according to assigned group markings, enhancing security and consistency. + +## User story + +> *As a user belonging to a single group, I want my access to groups, to be restricted to the markings assigned to my group, so that I only see what I am allowed to access.* + +## Acceptance criteria + +- **AC1** — Given I browse groups, When access is evaluated, Then I can only see groups whose marking matches or is below my group's assigned markings. + +--- + +# Sub-task: US5 — Highest marking applies when user belongs to multiple groups + +## Properties + +- **Task ID:** 605 +- **Status:** Business Refinement needed +- **Status 1:** Not started +- **Parent-task:** Task 2 — Assign Markings to users +- **Verticals:** #3 Easy-to-Use & consistent platform + +## AI Summary + +- 📌 Highest marking determines user access when belonging to multiple groups. +- 🔄 Access updates automatically when groups are added or removed. + +## User story + +> *As a user belonging to multiple groups, I want my access level to reflect the highest marking across all my groups, so that I am not unnecessarily restricted.* + +## Acceptance criteria + +- **AC1** — Given I browse groups, When access is evaluated, Then I can only see groups up to the highest marking level across all my groups. +- **AC2** — Given I am removed from a group, When access is recalculated, Then my access reflects only my remaining groups' markings. +# Task 2 — Assign Markings to users + +## Properties + +- **Task ID:** 591 +- **Status:** Business Refinement needed +- **Status 1:** Not started +- **Verticals:** #3 Easy-to-Use & consistent platform +- **EPIC:** https://app.notion.com/p/2c58fce17f2a803081dcf80b5a591db9 +- **Sub-tasks:** + - US1 — View markings assigned to a group + - US2 — Assign a marking to a group + - US3 — Remove a marking from a group + - US4 — Access control based on group marking . + - US5 — Highest marking applies when user belongs to multiple groups + +## AI Summary + +- 📌 Assign marking definitions to groups in OpenAEV, letting users inherit the highest marking from their groups. +- 🛠️ Admins can view, add, edit, or remove group markings and manage group membership to control object access. + +--- + +# 🎯 Business Context (👥 PM + Stakeholders) + +## Use Case & Business Goals + +Task 2 focuses on assigning marking definitions to groups in OpenAEV, following the same model as OpenCTI. A group can be assigned one or more markings — members of that group will only see and interact with objects whose marking level matches or is below their group's assigned markings. When a user belongs to multiple groups, the highest marking applies. + +## 👤 Users & Personas + +- **Administrator** ⇒ assigns markings to groups, manages group membership +- **User with the right capability** ⇒ views and interacts with objects based on their group's marking level + +# ⚠️ Important Flags + +| Flag | Value | +|---|---| +| Has **Breaking changes** | | +| Has **Data Model updates** | | +| Has **RBAC changes** | | +| Requires **Feature Flag** | | +| Targets master (minor release asap) | | +| Has impact on Import/Export | | + +# 🤝 Decisions Log + +| Date | Decision | Validated by Product? | Validated by Technical? | Link to related Meeting | +|---|---|---|---|---| +| --- | | | | | +| | Markings are assigned at the Group level (not Role level), following OpenCTI's architecture to keep a consistent mental model for end users | | | | + +--- + +# 🧠 WHAT DO WE WANT (Business Refinement) + +## 🧭 User Flow (mapped to user stories) + +### Preconditions (dependency on Task 1) + +- A marking definition exists (created/managed in Task 1). +- The admin has granted the appropriate permissions so the Administrator can manage group markings. + +### Flow A — View current group markings + +1. Administrator opens a **Group** and navigates to its **Markings** section. *(US1)* +2. Administrator sees the list of markings currently assigned to the group. *(US1)* + +### Flow B — Assign markings to a group + +1. Administrator opens a group and clicks **Edit** (or **Manage markings**). *(US2)* +2. Administrator selects one or more markings and saves. *(US2)* +3. Administrator sees the updated markings displayed on the group. *(US1)* + +### Flow C — Remove a marking from a group + +1. Administrator opens the group’s markings and removes a marking, then saves. *(US3)* +2. The marking is no longer listed on the group. *(US1)* + +### Flow D — Add users to groups (so they inherit markings) + +1. Administrator opens a group and goes to **Members**. +2. Administrator adds/removes users in the group and saves. +3. Users’ effective marking level updates (highest marking across their groups). *(US5)* +4. Access is enforced based on group markings. *(US4)* + +## 📜 User Stories + +### User stories pages + +- US1 — View markings assigned to a group +- US2 — Assign a marking to a group +- US3 — Remove a marking from a group +- US4 — Access control based on group marking . +- US5 — Highest marking applies when user belongs to multiple groups + +# Sub-task: US1 — View markings assigned to a group + +## Properties + +- **Task ID:** 602 +- **Status:** Business Refinement needed +- **Status 1:** Not started +- **Parent-task:** Task 2 — Assign Markings to users +- **Verticals:** #3 Easy-to-Use & consistent platform + +## AI Summary + +- 📋 View the list of markings assigned to a group on the group detail page. +- ✅ Shows each marking’s name and color; displays an empty state if no markings are assigned. + +## User story + +> *As a user with the right capability, I want to view the list of markings assigned to a group, so that I can understand what marking levels are accessible to members of that group.* + +## Acceptance criteria + +- **AC1** — Given I am on the group detail page, When I open a group, Then I see a "Markings" section listing all markings currently assigned to that group. +- **AC2** — Given no markings are assigned to the group, When I open the Markings section, Then I see an empty state. +- **AC3** — Given markings are assigned, When I view the Markings section, Then each marking is displayed with its name and color. + +--- + +# Sub-task: US2 — Assign a marking to a group + +## Properties + +- **Task ID:** 601 +- **Status:** Business Refinement needed +- **Status 1:** Not started +- **Parent-task:** Task 2 — Assign Markings to users +- **Verticals:** #3 Easy-to-Use & consistent platform + +## AI Summary + +- 📋 **User story:** Enable users with proper permissions to assign a marking to a group, allowing group members to access objects tagged with that marking. +- ✅ **Acceptance criteria:** Add a marking via a selector on the group detail page, ensure selected markings are saved, prevent already-assigned markings from appearing again, and display the new marking in the Markings list. + +## User story + +> *As a user with the right capability, I want to assign a marking to a group, so that members of that group can access objects with that marking.* + +## Acceptance criteria + +- **AC1** — Given I am on the group detail page, When I click to add a marking, Then a selector opens showing available markings. +- **AC2** — Given I select a marking from the list, When I confirm, Then the marking is added to the group. +- **AC3** — Given a marking is already assigned to the group, When I open the selector, Then that marking does not appear as an option. +- **AC4** — Given the assignment is saved, When I view the Markings section, Then the new marking appears in the list. + +--- + +# Sub-task: US3 — Remove a marking from a group + +## Properties + +- **Task ID:** 604 +- **Status:** Business Refinement needed +- **Status 1:** Not started +- **Parent-task:** Task 2 — Assign Markings to users +- **Verticals:** #3 Easy-to-Use & consistent platform + +## AI Summary + +- 📌 User story: Enable users with proper rights to remove a marking from a group, restricting marking levels for group members. +- ✅ Acceptance criteria: Show remove action per marking, confirm before deletion, and ensure the marking disappears after confirmation. + +## User story + +> *As a user with the right capability, I want to remove a marking from a group, so that I can restrict what marking levels are accessible to members of that group.* + +## Acceptance criteria + +- **AC1** — Given I am on the group detail page, When I view the Markings section, Then I see a remove action next to each assigned marking. +- **AC2** — Given I click remove on a marking, When the action is triggered, Then a confirmation is shown before deletion. +- **AC3** — Given I confirm the removal, When it is saved, Then the marking no longer appears in the group's Markings section. + +--- + +# Sub-task: US4 — Access control based on group marking . + +## Properties + +- **Task ID:** 603 +- **Status:** Business Refinement needed +- **Status 1:** Not started +- **Parent-task:** Task 2 — Assign Markings to users +- **Verticals:** #3 Easy-to-Use & consistent platform + +## AI Summary + +- 🎯 Develop group-based access control so users only see groups with markings matching or below their own. +- ✅ Ensure the feature restricts visibility according to assigned group markings, enhancing security and consistency. + +## User story + +> *As a user belonging to a single group, I want my access to groups , to be restricted to the markings assigned to my group, so that I only see what I am allowed to access.* + +## Acceptance criteria + +- **AC1** — Given I browse groups, When access is evaluated, Then I can only see groups whose marking matches or is below my group's assigned markings. + +--- + +# Sub-task: US5 — Highest marking applies when user belongs to multiple groups + +## Properties + +- **Task ID:** 605 +- **Status:** Business Refinement needed +- **Status 1:** Not started +- **Parent-task:** Task 2 — Assign Markings to users +- **Verticals:** #3 Easy-to-Use & consistent platform + +## AI Summary + +- 📌 Highest marking determines user access when belonging to multiple groups. +- 🔄 Access updates automatically when groups are added or removed. + +## User story + +> *As a user belonging to multiple groups, I want my access level to reflect the highest marking across all my groups, so that I am not unnecessarily restricted.* + +## Acceptance criteria + +- **AC1** — Given I browse groups, When access is evaluated, Then I can only see groups up to the highest marking level across all my groups. +- **AC2** — Given I am removed from a group, When access is recalculated, Then my access reflects only my remaining groups' markings. + +# Task 3 — Assign Markings to Assets + +## Properties + +- **Task ID:** 592 +- **Status:** Business Refinement needed +- **Status 1:** Not started +- **Verticals:** #3 Easy-to-Use & consistent platform +- **EPIC:** https://app.notion.com/p/2c58fce17f2a803081dcf80b5a591db9 +- **Sub-tasks:** + - US1 — Assign a marking to an Asset Group + - US2 — Assign a marking to an Endpoint + - US3 — Assign a marking to a Security Platform + - US4 — Assign a marking to a Credential + - US5 — Only users with the matching marking can see assets + +## AI Summary + +- 🎯 Assign markings to assets (Asset Groups, Endpoints, Security Platforms) in OpenAEV, mirroring OpenCTI’s model. +- 🔐 Users with proper capability can set, update, or remove markings, controlling asset visibility based on group-marking alignment. +- 📊 Ensure feature flags, breaking-change awareness, and telemetry for usage tracking; keep this field updatable. + +--- + +# 🎯 Business Context (👥 PM + Stakeholders) + +## Use Case & Business Goals + +Task 3 focuses on assigning marking definitions to **assets** in OpenAEV, following the same model as OpenCTI. Assets are divided into three types: **Asset Groups**, **Endpoints**, and **Security Platforms**. Users with the right capability can assign a marking to any of these asset types via the existing update flow. + +Once markings are set on assets: + +- Only users whose **group markings match (or are above / include)** the asset’s marking can see it +- Assets with **no marking** remain visible to everyone (until defined otherwise) + +## 👤 Users & Personas + +- **Administrator** ⇒ manages group membership and group markings (Task 2 dependency) +- **User with the right capability** ⇒ assigns / updates / removes markings on assets +- **Standard user** ⇒ can only view assets within their authorized marking scope + +# ⚠️ Important Flags + +| Flag | Value | +|---|---| +| Has **Breaking changes** | yes | +| Has **Data Model updates** | | +| Has **RBAC changes** | | +| Requires **Feature Flag** | | +| Targets master (minor release asap) | | +| Has impact on Import/Export | | + +# 🤝 Decisions Log + +| Date | Decision | Validated by Product? | Validated by Technical? | Link to related Meeting | +|---|---|---|---|---| +| --- | | | | | + +--- + +# 🧠 WHAT DO WE WANT (Business Refinement) + +## 🧭 User Flow (mapped to user stories) + +### Preconditions + +- Marking definitions exist (created/managed in **Task 1**). +- The user has the capability to update the relevant asset type (Asset Group / Endpoint / Security Platform / Credential). + +### Flow A — Assign a marking to an asset (Asset Group / Endpoint / Security Platform / Credential) + +1. User opens the asset detail page and clicks **Update**. *(US1/US2/US3/US4 depending on asset type)* +2. User selects a **Marking** value and saves. *(US1/US2/US3/US4)* +3. The asset displays the selected marking; user can later change or remove it via the same update flow. *(US1/US2/US3/US4)* + +### Flow B — Visibility & access control for marked assets (dependency only for US5) + +**Dependency:** requires **Task 2** (group markings) so the platform can compare the asset marking with the user’s effective group markings. + +1. User navigates tries to access a specific asset. +2. If the user’s group markings match/cover the asset’s marking, the asset is visible. +3. If not, the asset is hidden / access is denied. +4. Assets with **no marking** remain visible to everyone. + +## 📜 User Stories + +### User stories pages + +- US1 — Assign a marking to an Asset Group +- US2 — Assign a marking to an Endpoint +- US3 — Assign a marking to a Security Platform +- US4 — Assign a marking to a Credential +- US5 — Only users with the matching marking can see assets + +# Sub-task: US1 — Assign a marking to an Asset Group + +## Properties + +- **Task ID:** 606 +- **Status:** Business Refinement needed +- **Status 1:** Not started +- **Parent-task:** Task 3 — Assign Markings to Assets +- **Verticals:** #3 Easy-to-Use & consistent platform + +## AI Summary + +- 📌 User story: Assign a marking to an asset group to restrict access based on matching markings. +- ✅ Acceptance criteria: Ability to select, save, view, change, or remove the marking on the Asset Group detail page. + +## User story + +> *As a user with the right capability, I want to assign a marking to an asset group, so that access to that asset group is restricted to users with the matching marking.* + +## Acceptance criteria + +- **AC1** — Given I am on the Asset Group detail page, When I click Update, Then I see a marking field where I can select a marking. +- **AC2** — Given I select a marking and save, When I view the Asset Group, Then the assigned marking is displayed. +- **AC3** — Given a marking is already assigned, When I click Update, Then I can change or remove the existing marking. + +--- + +# Sub-task: US2 — Assign a marking to an Endpoint + +## Properties + +- **Task ID:** 607 +- **Status:** Business Refinement needed +- **Status 1:** Not started +- **Parent-task:** Task 3 — Assign Markings to Assets +- **Verticals:** #3 Easy-to-Use & consistent platform + +## AI Summary + +- 📌 Assign a marking to an endpoint to restrict access based on user capabilities. +- 🛠️ Users can add, change, or remove the marking via the Endpoint detail page’s Update function. + +## User story + +> *As a user with the right capability, I want to assign a marking to an endpoint, so that access to that endpoint is restricted to users with the matching marking.* + +## Acceptance criteria + +- **AC1** — Given I am on the Endpoint detail page, When I click Update, Then I see a marking field where I can select a marking. +- **AC2** — Given I select a marking and save, When I view the Endpoint, Then the assigned marking is displayed. +- **AC3** — Given a marking is already assigned, When I click Update, Then I can change or remove the existing marking. + +--- + +# Sub-task: US3 — Assign a marking to a Security Platform + +## Properties + +- **Task ID:** 609 +- **Status:** Business Refinement needed +- **Status 1:** Not started +- **Parent-task:** Task 3 — Assign Markings to Assets +- **Verticals:** #3 Easy-to-Use & consistent platform + +## AI Summary + +- 🛡️ Assign a marking to a security platform to restrict access based on matching markings. +- 🔧 Users can add, change, or remove the marking directly from the platform’s detail page. + +## User story + +> *As a user with the right capability, I want to assign a marking to a security platform, so that access to that security platform is restricted to users with the matching marking.* + +## Acceptance criteria + +- **AC1** — Given I am on the Security Platform detail page, When I click Update, Then I see a marking field where I can select a marking. +- **AC2** — Given I select a marking and save, When I view the Security Platform, Then the assigned marking is displayed. +- **AC3** — Given a marking is already assigned, When I click Update, Then I can change or remove the existing marking. + +--- + +# Sub-task: US4 — Assign a marking to a Credential + +## Properties + +- **Task ID:** 620 +- **Status:** Business Refinement needed +- **Status 1:** Not started +- **Parent-task:** Task 3 — Assign Markings to Assets +- **Verticals:** #3 Easy-to-Use & consistent platform + +## AI Summary + +- 📌 Assign a marking to a credential to control access based on matching markings. +- ✏️ Users can add, change, or remove the marking via the Credential detail page’s **Marking** field. + +## User story + +> *As a user with the right capability, I want to assign a marking to a credential, so that access to that credential is restricted to users with the matching marking.* + +## Acceptance criteria + +- **AC1** — Given I am on the Credential detail page, When I click Update (or Edit), Then I see a **Marking** field where I can select a marking. +- **AC2** — Given I select a marking and save, When I view the Credential, Then the assigned marking is displayed. +- **AC3** — Given a marking is already assigned, When I click Update (or Edit), Then I can change or remove the existing marking. + +--- + +# Sub-task: US5 — Only users with the matching marking can see assets + +## Properties + +- **Task ID:** 608 +- **Status:** Business Refinement needed +- **Status 1:** Not started +- **Parent-task:** Task 3 — Assign Markings to Assets +- **Verticals:** #3 Easy-to-Use & consistent platform + +## AI Summary + +- 🔐 Enable users to view only assets whose marking matches or is lower than their group's assigned marking. +- 🚫 Deny access to assets with higher markings and allow unrestricted view of unmarked assets. + +## User story + +> *As a user, I want to only see assets whose marking matches or is below my group's assigned marking, so that I cannot access assets I am not allowed to see.* + +## Acceptance criteria + +- **AC1** — Given an asset has a marking assigned, When I browse assets, Then I only see assets whose marking matches or is below my group's marking. +- **AC2** — Given an asset has a marking I do not have access to, When I try to access it, Then access is denied. +- **AC3** — Given an asset has no marking assigned, When I browse assets, Then it is visible to all users. \ No newline at end of file diff --git a/openaev-api/src/main/java/io/openaev/api/asset/AssetMarkingsApi.java b/openaev-api/src/main/java/io/openaev/api/asset/AssetMarkingsApi.java new file mode 100644 index 00000000000..4920e615de0 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/api/asset/AssetMarkingsApi.java @@ -0,0 +1,79 @@ +package io.openaev.api.asset; + +import static io.openaev.api.asset.AssetOptionsApi.ASSET_URI; +import static io.openaev.api.asset.AssetOptionsApi.TENANT_ASSET_URI; + +import io.openaev.aop.AccessControl; +import io.openaev.api.asset.dto.AssetUpdateMarkingsInput; +import io.openaev.config.TenantWriteScopeResolver; +import io.openaev.context.TxCtx; +import io.openaev.database.model.Action; +import io.openaev.database.model.Asset; +import io.openaev.database.model.ResourceType; +import io.openaev.rest.helper.RestBehavior; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +/** + * Assigns markings to an asset (design step 3.3). + * + *

Deliberately on {@code /api/assets} rather than {@code /api/endpoints}: {@code marking_ids} + * lives on the {@code assets} table, so one endpoint marks every asset category — endpoint, + * security platform, AI target — instead of one endpoint per subtype that would each have to repeat + * the same guard. + */ +@RestController +@RequiredArgsConstructor +@Tag( + name = "Asset markings", + description = "Assign sensitivity markings to an asset, whatever its category.") +public class AssetMarkingsApi extends RestBehavior { + + private final AssetMarkingsService assetMarkingsService; + private final TenantWriteScopeResolver writeScopeResolver; + + @PutMapping({ASSET_URI + "/{assetId}/markings", TENANT_ASSET_URI + "/{assetId}/markings"}) + @Transactional(rollbackFor = Exception.class) + @AccessControl( + resourceId = "#assetId", + actionPerformed = Action.WRITE, + resourceType = ResourceType.ASSET) + @Operation( + summary = "Replace the markings carried by an asset", + description = + "Replaces the whole set: an empty list clears every marking and makes the asset visible" + + " to everyone again. A caller may only assign markings they hold themselves, and" + + " only markings defined in their own tenant. Removing a marking is recorded as a" + + " declassification.") + @ApiResponses( + value = { + @ApiResponse(responseCode = "200", description = "Asset updated"), + @ApiResponse(responseCode = "403", description = "Assigning a marking the caller lacks"), + @ApiResponse( + responseCode = "404", + description = + "Asset or marking not found - including an asset marked above the caller's" + + " clearance, which is indistinguishable from one that does not exist") + }) + // TODO: replace with the "Assign marking" capability chain (design Q8) once Task 1 lands. The + // asset's own WRITE control is the honest interim, matching the group markings endpoint. + public Asset updateAssetMarkings( + TxCtx ctx, + @PathVariable @NotBlank final String assetId, + @Valid @RequestBody final AssetUpdateMarkingsInput input) { + // Tenant resolved here and passed down, per the multi-tenancy convention: the service never + // touches TenantContext. It is the tenant whose clearance the caller is checked against. + return assetMarkingsService.updateAssetMarkings( + writeScopeResolver.tenantForWrite(ctx, null), assetId, input); + } +} diff --git a/openaev-api/src/main/java/io/openaev/api/asset/AssetMarkingsService.java b/openaev-api/src/main/java/io/openaev/api/asset/AssetMarkingsService.java new file mode 100644 index 00000000000..bddf75a9073 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/api/asset/AssetMarkingsService.java @@ -0,0 +1,118 @@ +package io.openaev.api.asset; + +import static io.openaev.api.markings.MarkingEscalationValidator.assertCanAssignMarkings; + +import io.openaev.api.asset.dto.AssetUpdateMarkingsInput; +import io.openaev.config.cache.MarkingClearanceCacheManager; +import io.openaev.database.model.Asset; +import io.openaev.database.model.MarkingDefinition; +import io.openaev.database.model.User; +import io.openaev.database.repository.AssetRepository; +import io.openaev.database.repository.MarkingDefinitionRepository; +import io.openaev.rest.exception.ElementNotFoundException; +import io.openaev.service.UserService; +import jakarta.validation.constraints.NotBlank; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Writes the markings carried by an asset (design step 3.3). + * + *

The counterpart to {@code TenantGroupService.updateGroupMarkings}: that one grants a + * clearance to a group, this one puts a label on a row. Both go through the same + * {@link io.openaev.api.markings.MarkingEscalationValidator}, and for the same reason — a boundary + * you can widen for yourself is not a boundary. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AssetMarkingsService { + + private final AssetRepository assetRepository; + private final MarkingDefinitionRepository markingDefinitionRepository; + private final MarkingClearanceCacheManager markingClearanceCacheManager; + private final UserService userService; + + /** + * Replaces an asset's marking set. + * + *

🔴 No cache eviction here, deliberately. The rewritten predicate is {@code + * is_marking_set_allowed(marking_ids)} — the row's array is a function argument, re-read + * on every query; only the clearance lives in the cached GUC. Evicting on an asset write + * would be a no-op that looks like protection, which is worse than none: the next reader + * would assume a coverage that was never there. Eviction belongs only where a clearance shrinks + * (group membership, grant removal, definition delete, order lowered). + * + *

Self-lockout is impossible by construction, which is why there is no separate check + * for it. The validator enforces {@code requested ⊆ your clearance}, and a row is visible iff + * {@code row_markings ⊆ clearance}; so the asset you just marked is still readable by you. The + * same guard that stops escalation stops the lockout. + * + * @param tenantId the tenant whose clearance the caller is checked against + */ + @Transactional(rollbackFor = Exception.class) + public Asset updateAssetMarkings( + @NotBlank final String tenantId, + @NotBlank final String assetId, + final AssetUpdateMarkingsInput input) { + // Tenant-scoped lookup: a plain findById bypasses Hibernate's entity filters on a primary-key + // load. Once `assets` is marking-active the statement inspector also hides rows above the + // caller's clearance, so an asset they may not read is a 404 here - they cannot declassify what + // they cannot see. + Asset asset = + assetRepository + .findByIdAndTenantId(assetId, tenantId) + .orElseThrow(() -> new ElementNotFoundException("Asset not found: " + assetId)); + + Set uniqueMarkingIds = new LinkedHashSet<>(input.markingIds()); + List markings = new ArrayList<>(); + markingDefinitionRepository.findAllById(uniqueMarkingIds).forEach(markings::add); + if (markings.size() != uniqueMarkingIds.size()) { + throw new ElementNotFoundException( + "One or more marking definitions not found in the current tenant"); + } + + User currentUser = userService.currentUser(); + assertCanAssignMarkings( + markingClearanceCacheManager.findClearance( + currentUser.getId(), tenantId, currentUser.isAdminOrBypass()), + markings); + + Set previous = + asset.getMarkingIds() == null ? Set.of() : Set.copyOf(Arrays.asList(asset.getMarkingIds())); + logDeclassification(asset, currentUser, previous, uniqueMarkingIds); + + asset.setMarkingIds(uniqueMarkingIds.toArray(String[]::new)); + return assetRepository.save(asset); + } + + /** + * Records every marking removal and nothing else (design §4.3). + * + *

Only removals: adding a marking narrows who can read the asset and needs no explaining, + * while removing one widens it, and widening is the direction that turns into an incident. Logged + * rather than raised as a domain event because the platform has no audit-event facility yet — + * when one lands this is the single call site to move. + */ + private void logDeclassification( + Asset asset, User actor, Set previous, Set requested) { + List removed = + previous.stream().filter(id -> !requested.contains(id)).sorted().toList(); + if (removed.isEmpty()) { + return; + } + log.warn( + "Marking declassification: asset={} actor={} removedMarkings={} remainingMarkings={}", + asset.getId(), + actor.getId(), + removed, + requested); + } +} diff --git a/openaev-api/src/main/java/io/openaev/api/asset/dto/AssetUpdateMarkingsInput.java b/openaev-api/src/main/java/io/openaev/api/asset/dto/AssetUpdateMarkingsInput.java new file mode 100644 index 00000000000..0ca5c4b324f --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/api/asset/dto/AssetUpdateMarkingsInput.java @@ -0,0 +1,21 @@ +package io.openaev.api.asset.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +/** + * The markings carried by an asset — its sensitivity labels, not a clearance. + * + *

Replace-the-whole-set, like {@code GroupUpdateMarkingsInput}: an empty list clears every + * marking and makes the asset visible to everyone again. A PATCH-style add/remove would make "what + * is this asset marked with?" depend on request ordering, which is the wrong property for a + * security boundary. + */ +public record AssetUpdateMarkingsInput( + @JsonProperty("asset_markings") @NotNull List markingIds) { + + public AssetUpdateMarkingsInput { + markingIds = markingIds == null ? List.of() : List.copyOf(markingIds); + } +} diff --git a/openaev-api/src/main/java/io/openaev/api/groups/TenantGroupApi.java b/openaev-api/src/main/java/io/openaev/api/groups/TenantGroupApi.java index 72f7c8d9d17..c9fed8cccf7 100644 --- a/openaev-api/src/main/java/io/openaev/api/groups/TenantGroupApi.java +++ b/openaev-api/src/main/java/io/openaev/api/groups/TenantGroupApi.java @@ -6,7 +6,10 @@ import io.openaev.aop.AccessControl; import io.openaev.aop.LogExecutionTime; +import io.openaev.api.groups.dto.GroupUpdateMarkingsInput; import io.openaev.api.groups.dto.TenantGroupCreateInput; +import io.openaev.config.TenantWriteScopeResolver; +import io.openaev.context.TxCtx; import io.openaev.database.model.*; import io.openaev.rest.group.form.GroupGrantInput; import io.openaev.rest.group.form.GroupUpdateRolesInput; @@ -32,6 +35,7 @@ public class TenantGroupApi extends RestBehavior { public static final String TENANT_GROUP_URI = TENANT_PREFIX + "/groups"; private final TenantGroupService tenantGroupService; + private final TenantWriteScopeResolver writeScopeResolver; // -- CREATE -- @@ -107,6 +111,37 @@ public Group updateGroupRoles( return tenantGroupService.updateGroupRoles(groupId, input); } + @LogExecutionTime + @PutMapping("/{groupId}/markings") + @Transactional + @AccessControl( + resourceId = "#groupId", + actionPerformed = Action.WRITE, + resourceType = ResourceType.USER_GROUP) + @Operation( + summary = "Replace the markings a group grants its members", + description = + "Replaces the whole set: an empty list revokes every grant. A caller may only assign" + + " markings they hold themselves, and only markings defined in their own tenant." + + " Every member's cached clearance is evicted, so the change takes effect on their" + + " next request.") + @ApiResponses( + value = { + @ApiResponse(responseCode = "200", description = "Group updated"), + @ApiResponse(responseCode = "403", description = "Assigning a marking the caller lacks"), + @ApiResponse(responseCode = "404", description = "Group or marking not found") + }) + // TODO: replace with the "Assign marking" capability chain (design Q8) once Task 1 lands. The + // group's own WRITE control is the honest interim: it is what already governs who may change what + // a group grants, and the marking PoC is deliberately capability-free (design Q12). + public Group updateGroupMarkings( + TxCtx ctx, @PathVariable String groupId, @Valid @RequestBody GroupUpdateMarkingsInput input) { + // Tenant resolved here and passed down, per the multi-tenancy convention: the service never + // touches TenantContext. It is the tenant whose clearance the caller is checked against. + return tenantGroupService.updateGroupMarkings( + writeScopeResolver.tenantForWrite(ctx, null), groupId, input); + } + @LogExecutionTime @PutMapping("/{groupId}/information") @Transactional diff --git a/openaev-api/src/main/java/io/openaev/api/groups/dto/GroupUpdateMarkingsInput.java b/openaev-api/src/main/java/io/openaev/api/groups/dto/GroupUpdateMarkingsInput.java new file mode 100644 index 00000000000..06311b4dc41 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/api/groups/dto/GroupUpdateMarkingsInput.java @@ -0,0 +1,20 @@ +package io.openaev.api.groups.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +/** + * The markings a group grants its members. + * + *

Replace-the-whole-set, like {@code GroupUpdateUsersInput} and {@code GroupUpdateRolesInput}: + * an empty list revokes every grant. A PATCH-style add/remove would make "what does this group + * grant?" depend on request ordering, which is the wrong property for a security boundary. + */ +public record GroupUpdateMarkingsInput( + @JsonProperty("group_markings") @NotNull List markingIds) { + + public GroupUpdateMarkingsInput { + markingIds = markingIds == null ? List.of() : List.copyOf(markingIds); + } +} diff --git a/openaev-api/src/main/java/io/openaev/api/markings/MarkingDefinitionApi.java b/openaev-api/src/main/java/io/openaev/api/markings/MarkingDefinitionApi.java new file mode 100644 index 00000000000..a51fd805964 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/api/markings/MarkingDefinitionApi.java @@ -0,0 +1,109 @@ +package io.openaev.api.markings; + +import static io.openaev.api.markings.MarkingDefinitionMapper.toOutput; +import static io.openaev.config.TenantUriUtils.TENANT_PREFIX; + +import io.openaev.aop.AccessControl; +import io.openaev.api.markings.form.MarkingDefinitionInput; +import io.openaev.api.markings.response.MarkingDefinitionOutput; +import io.openaev.config.TenantWriteScopeResolver; +import io.openaev.context.TxCtx; +import io.openaev.database.model.Action; +import io.openaev.database.model.ResourceType; +import io.openaev.rest.helper.RestBehavior; +import io.openaev.utils.pagination.SearchPaginationInput; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.HttpStatus; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; + +/** + * The marking catalogue: the vocabulary that clearances and row attachments are both expressed in. + * + *

TODO: add Capa - Guarded by the existing tenant-settings capability chain rather than a + * marking-specific one — step 2.1 of the marking design is deliberately capability-free. + */ +@RestController +@RequestMapping({MarkingDefinitionApi.MARKING_URI, MarkingDefinitionApi.TENANT_MARKING_URI}) +@RequiredArgsConstructor +@Tag(name = "Marking definitions", description = "Manage the tenant's classification scales") +public class MarkingDefinitionApi extends RestBehavior { + + public static final String MARKING_URI = "/api/marking-definitions"; + + public static final String TENANT_MARKING_URI = TENANT_PREFIX + "/marking-definitions"; + + private final MarkingDefinitionService markingDefinitionService; + private final TenantWriteScopeResolver writeScopeResolver; + + // -- CREATE -- + + @Operation(summary = "Create a marking definition") + @AccessControl(actionPerformed = Action.CREATE, resourceType = ResourceType.MARKING_DEFINITION) + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Transactional + public MarkingDefinitionOutput create( + TxCtx ctx, @Valid @RequestBody MarkingDefinitionInput input) { + // Explicit write attribution: the row is stamped with the single tenant in the request scope, + // and the inspector rejects the INSERT if that tenant is outside it. + return toOutput( + markingDefinitionService.create(writeScopeResolver.tenantForWrite(ctx, null), input)); + } + + // -- READ -- + + @Operation(summary = "Get a marking definition by ID") + @AccessControl( + resourceId = "#markingId", + actionPerformed = Action.READ, + resourceType = ResourceType.MARKING_DEFINITION) + @GetMapping("/{markingId}") + @Transactional(readOnly = true) + public MarkingDefinitionOutput getById(TxCtx ctx, @PathVariable String markingId) { + return toOutput(markingDefinitionService.findById(markingId)); + } + + // -- SEARCH -- + + @Operation(summary = "Search marking definitions with pagination and filtering") + @AccessControl(actionPerformed = Action.SEARCH, resourceType = ResourceType.MARKING_DEFINITION) + @PostMapping("/search") + @Transactional(readOnly = true) + public Page search( + TxCtx ctx, @Valid @RequestBody SearchPaginationInput searchPaginationInput) { + return markingDefinitionService.search(searchPaginationInput); + } + + // -- UPDATE -- + + @Operation(summary = "Update a marking definition") + @AccessControl( + resourceId = "#markingId", + actionPerformed = Action.WRITE, + resourceType = ResourceType.MARKING_DEFINITION) + @PutMapping("/{markingId}") + @Transactional + public MarkingDefinitionOutput update( + TxCtx ctx, @PathVariable String markingId, @Valid @RequestBody MarkingDefinitionInput input) { + return toOutput(markingDefinitionService.update(markingId, input)); + } + + // -- DELETE -- + + @Operation(summary = "Delete a marking definition") + @AccessControl( + resourceId = "#markingId", + actionPerformed = Action.DELETE, + resourceType = ResourceType.MARKING_DEFINITION) + @DeleteMapping("/{markingId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Transactional + public void delete(TxCtx ctx, @PathVariable String markingId) { + markingDefinitionService.delete(markingId); + } +} diff --git a/openaev-api/src/main/java/io/openaev/api/markings/MarkingDefinitionDependenciesManager.java b/openaev-api/src/main/java/io/openaev/api/markings/MarkingDefinitionDependenciesManager.java new file mode 100644 index 00000000000..20a93fd9a37 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/api/markings/MarkingDefinitionDependenciesManager.java @@ -0,0 +1,68 @@ +package io.openaev.api.markings; + +import io.openaev.database.model.MarkingDefinition; +import io.openaev.database.model.Tenant; +import io.openaev.database.repository.MarkingDefinitionRepository; +import io.openaev.multitenancy.DependenciesManager; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Gives every new tenant the standard TLP and PAP scales, so a fresh tenant has a usable marking + * vocabulary without an administrator seeding one by hand. + * + *

Existing tenants were seeded by the {@code V6_20260825140000000__Add_marking_definitions} + * migration. The two lists must stay in step; the migration is the source of truth for the defaults + * and this class mirrors it. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(rollbackFor = Exception.class) +public class MarkingDefinitionDependenciesManager implements DependenciesManager { + + /** name, type, order, colour — mirrors the migration's seed. */ + private static final List DEFAULTS = + List.of( + new Default("TLP:CLEAR", MarkingDefinition.TYPE_TLP, 10, "#ffffff"), + new Default("TLP:GREEN", MarkingDefinition.TYPE_TLP, 20, "#2e7d32"), + new Default("TLP:AMBER", MarkingDefinition.TYPE_TLP, 30, "#d84315"), + new Default("TLP:AMBER+STRICT", MarkingDefinition.TYPE_TLP, 40, "#d84315"), + new Default("TLP:RED", MarkingDefinition.TYPE_TLP, 50, "#c62828"), + new Default("PAP:CLEAR", MarkingDefinition.TYPE_PAP, 10, "#ffffff"), + new Default("PAP:GREEN", MarkingDefinition.TYPE_PAP, 20, "#2e7d32"), + new Default("PAP:AMBER", MarkingDefinition.TYPE_PAP, 30, "#d84315"), + new Default("PAP:RED", MarkingDefinition.TYPE_PAP, 50, "#c62828")); + + private final MarkingDefinitionRepository markingDefinitionRepository; + + @Override + public void createDependencyForTenant(Tenant tenant) { + List markings = + DEFAULTS.stream().map(marking -> marking.toEntity(tenant)).toList(); + markingDefinitionRepository.saveAll(markings); + log.info( + "Seeded {} default marking definitions for tenant '{}'", markings.size(), tenant.getId()); + } + + @Override + public void deleteDependencyForTenant(String tenantId) { + // marking_definitions rows are cascade-deleted through the tenant_id foreign key. + } + + private record Default(String name, String type, int order, String color) { + + MarkingDefinition toEntity(Tenant tenant) { + MarkingDefinition marking = new MarkingDefinition(); + marking.setName(name); + marking.setType(type); + marking.setOrder(order); + marking.setColor(color); + marking.setTenant(tenant); + return marking; + } + } +} diff --git a/openaev-api/src/main/java/io/openaev/api/markings/MarkingDefinitionMapper.java b/openaev-api/src/main/java/io/openaev/api/markings/MarkingDefinitionMapper.java new file mode 100644 index 00000000000..d2ac870df6f --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/api/markings/MarkingDefinitionMapper.java @@ -0,0 +1,28 @@ +package io.openaev.api.markings; + +import io.openaev.api.markings.form.MarkingDefinitionInput; +import io.openaev.api.markings.response.MarkingDefinitionOutput; +import io.openaev.database.model.MarkingDefinition; + +public class MarkingDefinitionMapper { + + private MarkingDefinitionMapper() {} + + /** Applies an input onto an entity. Tenant attribution is the caller's job (v2 isolation). */ + public static MarkingDefinition apply(MarkingDefinition marking, MarkingDefinitionInput input) { + marking.setType(input.type()); + marking.setName(input.name()); + marking.setOrder(input.order()); + marking.setColor(input.color()); + return marking; + } + + public static MarkingDefinitionOutput toOutput(MarkingDefinition marking) { + return new MarkingDefinitionOutput( + marking.getId(), + marking.getType(), + marking.getName(), + marking.getOrder(), + marking.getColor()); + } +} diff --git a/openaev-api/src/main/java/io/openaev/api/markings/MarkingDefinitionQueryHelper.java b/openaev-api/src/main/java/io/openaev/api/markings/MarkingDefinitionQueryHelper.java new file mode 100644 index 00000000000..3721aa5f3fd --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/api/markings/MarkingDefinitionQueryHelper.java @@ -0,0 +1,41 @@ +package io.openaev.api.markings; + +import static io.openaev.api.markings.response.MarkingDefinitionOutput.*; + +import io.openaev.api.markings.response.MarkingDefinitionOutput; +import io.openaev.database.model.MarkingDefinition; +import jakarta.persistence.Tuple; +import jakarta.persistence.TypedQuery; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Root; +import java.util.List; + +public class MarkingDefinitionQueryHelper { + + private MarkingDefinitionQueryHelper() {} + + // -- SELECT -- + public static void select(CriteriaQuery cq, Root root) { + cq.multiselect( + root.get("id").alias(ALIAS_ID), + root.get("type").alias(ALIAS_TYPE), + root.get("name").alias(ALIAS_NAME), + root.get("order").alias(ALIAS_ORDER), + root.get("color").alias(ALIAS_COLOR)) + .distinct(true); + } + + // -- EXECUTION -- + public static List execution(TypedQuery query) { + return query.getResultList().stream() + .map( + tuple -> + new MarkingDefinitionOutput( + tuple.get(ALIAS_ID, String.class), + tuple.get(ALIAS_TYPE, String.class), + tuple.get(ALIAS_NAME, String.class), + tuple.get(ALIAS_ORDER, Integer.class), + tuple.get(ALIAS_COLOR, String.class))) + .toList(); + } +} diff --git a/openaev-api/src/main/java/io/openaev/api/markings/MarkingDefinitionService.java b/openaev-api/src/main/java/io/openaev/api/markings/MarkingDefinitionService.java new file mode 100644 index 00000000000..c62537af378 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/api/markings/MarkingDefinitionService.java @@ -0,0 +1,139 @@ +package io.openaev.api.markings; + +import static io.openaev.utils.pagination.CriteriaBuilderPagination.paginate; +import static io.openaev.utils.pagination.PaginationUtils.buildPaginationCriteriaBuilder; + +import io.openaev.api.markings.form.MarkingDefinitionInput; +import io.openaev.api.markings.response.MarkingDefinitionOutput; +import io.openaev.config.cache.MarkingClearanceCacheManager; +import io.openaev.database.model.MarkingDefinition; +import io.openaev.database.model.Tenant; +import io.openaev.database.repository.MarkingDefinitionRepository; +import io.openaev.rest.exception.BadRequestException; +import io.openaev.utils.pagination.SearchPaginationInput; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityNotFoundException; +import jakarta.persistence.PersistenceContext; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * CRUD over the marking catalogue. + * + *

Deliberately skimmed for the marking PoC (step 2.1): no clearance checks on the definitions + * themselves, and no assignment endpoints. Tenant isolation is entirely the v2 statement + * inspector's job, so nothing here mentions {@code TenantContext}. + */ +@Service +@RequiredArgsConstructor +@Transactional(rollbackFor = Exception.class) +public class MarkingDefinitionService { + + private final MarkingDefinitionRepository markingDefinitionRepository; + private final MarkingClearanceCacheManager markingClearanceCacheManager; + @PersistenceContext private EntityManager entityManager; + + // -- CREATE -- + + /** + * The tenant is resolved in the API layer and passed in, per the multi-tenancy convention: this + * service never touches {@code TenantContext}. + */ + public MarkingDefinition create( + @NotBlank String tenantId, @NotNull MarkingDefinitionInput input) { + assertNameIsFree(input.name(), null); + MarkingDefinition marking = MarkingDefinitionMapper.apply(new MarkingDefinition(), input); + marking.setTenant(new Tenant(tenantId)); + return markingDefinitionRepository.save(marking); + } + + // -- READ -- + + @Transactional(readOnly = true) + public MarkingDefinition findById(@NotBlank String id) { + return getOrThrow(id); + } + + @Transactional(readOnly = true) + public Page search(@NotNull SearchPaginationInput input) { + return buildPaginationCriteriaBuilder( + (spec, specCount, pageable) -> + paginate( + entityManager, + MarkingDefinition.class, + spec, + specCount, + pageable, + MarkingDefinitionQueryHelper::select, + MarkingDefinitionQueryHelper::execution), + input, + MarkingDefinition.class); + } + + // -- UPDATE -- + + /** + * Evicts every cached clearance, because {@code order} and {@code type} are the inputs the + * resolver expands a grant against — not just labels. Raising a marking's order pushes it above + * clearances that previously covered it, so a stale entry keeps granting a marking the new data + * no longer justifies: fail-open. Blunt because the affected set is "everyone holding a grant of + * this type", and computing it is itself a query (see {@link + * MarkingClearanceCacheManager#evictAll}). + */ + public MarkingDefinition update(@NotBlank String id, @NotNull MarkingDefinitionInput input) { + MarkingDefinition existing = getOrThrow(id); + assertNameIsFree(input.name(), id); + MarkingDefinition saved = + markingDefinitionRepository.save(MarkingDefinitionMapper.apply(existing, input)); + markingClearanceCacheManager.evictAll(); + return saved; + } + + // -- DELETE -- + + /** + * Hard delete. {@code groups_markings} rows cascade, so no group keeps a dangling grant. + * + *

Rows that already carry this marking in their {@code marking_ids} array are not + * scrubbed here: there is no foreign key to cascade through, and no table is marking-activated + * yet. That scrub is part of activation (design §6.8) and lands with step 3. + */ + public void delete(@NotBlank String id) { + markingDefinitionRepository.delete(getOrThrow(id)); + // The cascade removes the grants, but not the clearances already derived from them. + markingClearanceCacheManager.evictAll(); + } + + // -- PRIVATE -- + + /** + * Non-transactional lookup for internal callers. Calling the public {@code findById} from within + * this class would be a self-invocation: the Spring proxy is bypassed, so neither the transaction + * nor the tenant scope would apply. + */ + private MarkingDefinition getOrThrow(String id) { + return markingDefinitionRepository + .findById(id) + .orElseThrow(() -> new EntityNotFoundException("Marking definition not found: " + id)); + } + + /** Mirrors the {@code (marking_name, tenant_id)} unique index with a readable error. */ + private void assertNameIsFree(String name, String allowedId) { + boolean clash = + markingDefinitionRepository.findAllByName(name).stream() + .anyMatch(existing -> !existing.getId().equals(allowedId)); + if (clash) { + throw new BadRequestException("Marking name already used: " + name); + } + } + + @Transactional(readOnly = true) + public List findAllByType(@NotBlank String type) { + return markingDefinitionRepository.findAllByTypeOrderByOrderAsc(type); + } +} diff --git a/openaev-api/src/main/java/io/openaev/api/markings/MarkingEscalationValidator.java b/openaev-api/src/main/java/io/openaev/api/markings/MarkingEscalationValidator.java new file mode 100644 index 00000000000..88dedcfd799 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/api/markings/MarkingEscalationValidator.java @@ -0,0 +1,58 @@ +package io.openaev.api.markings; + +import io.openaev.context.MarkingCtx; +import io.openaev.database.model.MarkingDefinition; +import io.openaev.rest.exception.ForbiddenException; +import java.util.Collection; +import java.util.List; +import java.util.Set; + +/** + * Guards against clearance escalation: you may not grant a marking you do not hold yourself. + * + *

Design decision Q7. Without this, marking is not a boundary at all — anyone able to manage a + * group could put themselves in it, grant it {@code TLP:RED}, and read everything. The capability + * to manage groups would silently become the capability to read every marked row. + * + *

Checked against the resolved clearance, not the raw grants. {@link MarkingCtx} is + * already expanded by {@code MarkingScopeResolver}, so a user holding {@code TLP:AMBER} may grant + * {@code TLP:GREEN} — which is right: they can already read every {@code TLP:GREEN} row, so + * granting it discloses nothing they could not have disclosed by other means. Checking raw grants + * instead would forbid that and be merely annoying, not safer. + * + *

A bypassing caller resolves to the whole tenant scale (see {@code MarkingScopeResolver}), so + * they pass this check without needing a special case here. + * + *

Mirrors {@code PrivilegeEscalationValidator}, which does the same job for capabilities. + */ +public final class MarkingEscalationValidator { + + private MarkingEscalationValidator() {} + + /** + * @param clearance the caller's own clearance in the tenant being written to + * @param requested the markings they are trying to grant + * @throws ForbiddenException naming the markings they do not hold + */ + public static void assertCanAssignMarkings( + MarkingCtx clearance, Collection requested) { + Set held = + clearance instanceof MarkingCtx.Restricted restricted + ? Set.copyOf(restricted.markingIds()) + : Set.of(); + + List unheld = + requested.stream() + .filter(marking -> !held.contains(marking.getId())) + .map(MarkingDefinition::getName) + .sorted() + .toList(); + + if (!unheld.isEmpty()) { + // Names rather than ids: the caller chose these in a UI that shows names, and an id would + // make a legitimate mistake unreadable. + throw new ForbiddenException( + "Cannot assign markings you do not hold: " + String.join(", ", unheld)); + } + } +} diff --git a/openaev-api/src/main/java/io/openaev/api/markings/form/MarkingDefinitionInput.java b/openaev-api/src/main/java/io/openaev/api/markings/form/MarkingDefinitionInput.java new file mode 100644 index 00000000000..5c9e23206b5 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/api/markings/form/MarkingDefinitionInput.java @@ -0,0 +1,30 @@ +package io.openaev.api.markings.form; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; + +public record MarkingDefinitionInput( + @JsonProperty("marking_type") + @NotBlank + @Schema(description = "Classification scale, e.g. TLP or PAP") + String type, + @JsonProperty("marking_name") + @NotBlank + @Schema(description = "Name of the marking, unique within the tenant, e.g. TLP:RED") + String name, + @JsonProperty("marking_order") + @NotNull + @Positive + @Schema( + description = + "Rank within the scale — higher is more restrictive. Holding a level implies" + + " holding every lower level of the same scale.") + Integer order, + @JsonProperty("marking_color") + @Pattern(regexp = "^#[0-9a-fA-F]{6}$", message = "must be a hex colour such as #c62828") + @Schema(description = "Display colour, as a hex code") + String color) {} diff --git a/openaev-api/src/main/java/io/openaev/api/markings/response/MarkingDefinitionOutput.java b/openaev-api/src/main/java/io/openaev/api/markings/response/MarkingDefinitionOutput.java new file mode 100644 index 00000000000..b0342dc3dc2 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/api/markings/response/MarkingDefinitionOutput.java @@ -0,0 +1,23 @@ +package io.openaev.api.markings.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/** + * Note the absence of {@code tenant_id}: the marking catalogue is tenant-scoped, but the tenant is + * never exposed to the client. + */ +public record MarkingDefinitionOutput( + @JsonProperty(ALIAS_ID) @NotBlank String id, + @JsonProperty(ALIAS_TYPE) @NotBlank String type, + @JsonProperty(ALIAS_NAME) @NotBlank String name, + @JsonProperty(ALIAS_ORDER) @NotNull Integer order, + @JsonProperty(ALIAS_COLOR) String color) { + + public static final String ALIAS_ID = "marking_id"; + public static final String ALIAS_TYPE = "marking_type"; + public static final String ALIAS_NAME = "marking_name"; + public static final String ALIAS_ORDER = "marking_order"; + public static final String ALIAS_COLOR = "marking_color"; +} diff --git a/openaev-api/src/main/java/io/openaev/config/CachingConfig.java b/openaev-api/src/main/java/io/openaev/config/CachingConfig.java index 7a62d622f61..750b05a1798 100644 --- a/openaev-api/src/main/java/io/openaev/config/CachingConfig.java +++ b/openaev-api/src/main/java/io/openaev/config/CachingConfig.java @@ -27,7 +27,12 @@ public CacheManager cacheManager() { */ CaffeineCacheManager cacheManager = new CaffeineCacheManager( - "license", "global", "adminUsers", "tenantMembership", "userTenantIds"); + "license", + "global", + "adminUsers", + "tenantMembership", + "userTenantIds", + "markingClearance"); cacheManager.setCaffeine( Caffeine.newBuilder().expireAfterWrite(Duration.ofDays(1)).maximumSize(100)); @@ -42,6 +47,13 @@ public CacheManager cacheManager() { "userTenantIds", Caffeine.newBuilder().expireAfterWrite(Duration.ofMinutes(5)).maximumSize(10_000).build()); + // Marking clearance: keyed by userId:tenantId:bypass. The TTL is a backstop, not the + // invalidation strategy — a stale clearance is larger than the data justifies, so it fails + // OPEN. Every reduction must evict explicitly (see MarkingClearanceCacheManager). + cacheManager.registerCustomCache( + "markingClearance", + Caffeine.newBuilder().expireAfterWrite(Duration.ofMinutes(5)).maximumSize(10_000).build()); + return cacheManager; } diff --git a/openaev-api/src/main/java/io/openaev/config/HttpMarkingScopeSupplier.java b/openaev-api/src/main/java/io/openaev/config/HttpMarkingScopeSupplier.java new file mode 100644 index 00000000000..c584c39c393 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/config/HttpMarkingScopeSupplier.java @@ -0,0 +1,65 @@ +package io.openaev.config; + +import io.openaev.config.cache.MarkingClearanceCacheManager; +import io.openaev.context.MarkingCtx; +import io.openaev.context.MarkingScopeSupplier; +import io.openaev.context.TxCtx; +import io.openaev.database.model.User; +import io.openaev.service.UserService; +import java.util.LinkedHashSet; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Component; + +/** + * Derives the HTTP caller's marking clearance for the scope aspect. + * + *

This is the API-side half of {@link MarkingScopeSupplier}: the aspect lives in {@code + * openaev-model} and cannot reach the security context or the clearance cache, both of which live + * here. + * + *

Fail-closed, and note what that means for marking. No principal, an anonymous caller, + * or a tenant scope that grants nothing all yield {@link MarkingCtx#none()} — which still admits + * unmarked rows. The failure mode is a narrower result set, never a wider one. That asymmetry with + * the tenant dimension (where fail-closed means zero rows) is deliberate: a marking is a + * sensitivity label on a subset of rows, not a boundary around all of them. + * + *

{@link TxCtx.AllTenants} resolves to {@code none()} rather than to every marking on the + * platform. It is an unresolved background intention that should never reach an HTTP transaction; + * granting a platform-wide clearance for it would turn a plumbing mistake into a disclosure. + */ +@Component +@RequiredArgsConstructor +public class HttpMarkingScopeSupplier implements MarkingScopeSupplier { + + private final MarkingClearanceCacheManager clearanceCache; + + // @Lazy: UserService sits high in the service graph, and this component is pulled in by an aspect + // that many of those services are themselves advised by. + @Lazy private final UserService userService; + + @Override + public MarkingCtx clearanceFor(TxCtx tenantScope) { + if (!(tenantScope instanceof TxCtx.Restricted restricted)) { + // Missing is already fail-closed on the tenant side; AllTenants is a background intention. + return MarkingCtx.none(); + } + User currentUser = userService.currentUserOrNull(); + if (currentUser == null) { + return MarkingCtx.none(); + } + boolean bypass = currentUser.isAdminOrBypass(); + + // A marking definition belongs to exactly one tenant, so ids cannot collide across them and the + // union is unambiguous: acting on N tenants means holding each one's clearance in that tenant. + Set markingIds = new LinkedHashSet<>(); + for (String tenantId : restricted.tenantIds()) { + MarkingCtx perTenant = clearanceCache.findClearance(currentUser.getId(), tenantId, bypass); + if (perTenant instanceof MarkingCtx.Restricted granted) { + markingIds.addAll(granted.markingIds()); + } + } + return markingIds.isEmpty() ? MarkingCtx.none() : MarkingCtx.forMarkings(markingIds); + } +} diff --git a/openaev-api/src/main/java/io/openaev/config/MarkedTable.java b/openaev-api/src/main/java/io/openaev/config/MarkedTable.java new file mode 100644 index 00000000000..72e56697911 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/config/MarkedTable.java @@ -0,0 +1,27 @@ +package io.openaev.config; + +import java.util.Locale; + +/** + * How one marked table stores its markings. A marking is a many-to-many association, but the set is + * kept inline on the row as a {@code text[]}, so — unlike a join-table model — the predicate needs + * nothing but the column name, and the marked table's primary key is irrelevant. That is what lets + * relationship tables, whose primary keys are composite, be marked with no special case. + * + * @param table the marked table, e.g. {@code assets} + * @param markingColumn the column holding its marking ids, by convention {@link #MARKING_COLUMN} + */ +public record MarkedTable(String table, String markingColumn) { + + /** The column holding the marking ids of a marked row — the convention derivation relies on. */ + public static final String MARKING_COLUMN = "marking_ids"; + + public MarkedTable(String table) { + this(table, MARKING_COLUMN); + } + + public MarkedTable { + table = table.toLowerCase(Locale.ROOT); + markingColumn = markingColumn.toLowerCase(Locale.ROOT); + } +} diff --git a/openaev-api/src/main/java/io/openaev/config/MarkedTables.java b/openaev-api/src/main/java/io/openaev/config/MarkedTables.java new file mode 100644 index 00000000000..a256c0eb2d5 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/config/MarkedTables.java @@ -0,0 +1,66 @@ +package io.openaev.config; + +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * The tables filtered by the marking dimension, indexed by table name (matched case-insensitively). + * Mirrors {@link TenantTables}, with a {@link MarkedTable} instead of a family because a marking is + * a set of ids rather than a single value. + */ +public record MarkedTables(Map byTable) { + + public static final MarkedTables EMPTY = new MarkedTables(Map.of()); + + public MarkedTables { + Map normalized = new LinkedHashMap<>(); + byTable.forEach((name, marked) -> normalized.put(name.toLowerCase(Locale.ROOT), marked)); + byTable = Map.copyOf(normalized); + } + + /** Strips the surrounding double quotes an SQL dialect may put around an identifier. */ + private static String unquote(String name) { + if (name.length() >= 2 && name.startsWith("\"") && name.endsWith("\"")) { + return name.substring(1, name.length() - 1); + } + return name; + } + + /** The marking metadata of a table, or null when the table is not marked. */ + public MarkedTable get(String table) { + return byTable.get(unquote(table).toLowerCase(Locale.ROOT)); + } + + public Set tableNames() { + return byTable.keySet(); + } + + /** + * Restricts these tables to an activation allowlist, the table-by-table rollout knob. An empty + * allowlist activates nothing, so the dimension stays inert. An entry that is not a known marked + * table fails fast, to surface a typo (or a missing marking column) at startup rather than + * silently leave a table unprotected. + */ + public MarkedTables restrictTo(Collection allowlist) { + Set allowed = new HashSet<>(); + allowlist.forEach(name -> allowed.add(name.toLowerCase(Locale.ROOT))); + Set unknown = new HashSet<>(allowed); + unknown.removeAll(byTable.keySet()); + if (!unknown.isEmpty()) { + throw new IllegalArgumentException( + "marking active-tables have no " + MarkedTable.MARKING_COLUMN + " column: " + unknown); + } + Map kept = new LinkedHashMap<>(); + byTable.forEach( + (name, marked) -> { + if (allowed.contains(name)) { + kept.put(name, marked); + } + }); + return new MarkedTables(kept); + } +} diff --git a/openaev-api/src/main/java/io/openaev/config/MarkingDimension.java b/openaev-api/src/main/java/io/openaev/config/MarkingDimension.java new file mode 100644 index 00000000000..56c880b6785 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/config/MarkingDimension.java @@ -0,0 +1,64 @@ +package io.openaev.config; + +import java.util.Set; + +/** + * The marking scope dimension: restricts every active marked table to the rows whose markings are + * all covered by the clearance in {@code app.current_markings}, through the {@code + * is_marking_set_allowed} SQL function. + * + *

Markings are many-to-many, but the set is stored inline on the row as a {@code text[]}, so the + * predicate is a local column test of the same shape as the tenant one: + * + *

{@code
+ * is_marking_set_allowed(t.marking_ids)
+ * }
+ * + * read as "keep this row if I hold every marking it carries". Three consequences follow, and they + * are the intended semantics: a row must satisfy every one of its markings (AND, the STIX + * reading), a row with no marking is visible to everyone for free (the empty set is contained in + * everything), and a marking can only ever reduce visibility. + * + *

Because the markings live in a column rather than a join table, the marked table's primary key + * never appears in the predicate — which is what lets relationship tables, whose keys are + * composite, be marked with no special case. + * + *

Reads and writes use the same predicate: seeing a row and being allowed to touch it are the + * same question here. Restricting which markings may be written is a service-layer concern, + * not something this rewrite can express — and under this shape it is also what keeps a nonexistent + * marking id out of the column, since no foreign key does. + */ +public final class MarkingDimension implements ScopeDimension { + + private final MarkedTables tables; + + public MarkingDimension(MarkedTables tables) { + this.tables = tables; + } + + @Override + public String name() { + return "marking"; + } + + @Override + public Set activeTables() { + return tables.tableNames(); + } + + @Override + public boolean covers(String table) { + return tables.get(table) != null; + } + + @Override + public String readPredicate(String table, String alias) { + MarkedTable marked = tables.get(table); + return "is_marking_set_allowed(" + alias + "." + marked.markingColumn() + ")"; + } + + @Override + public String writePredicate(String table, String alias) { + return readPredicate(table, alias); + } +} diff --git a/openaev-api/src/main/java/io/openaev/config/MarkingFilteringConfig.java b/openaev-api/src/main/java/io/openaev/config/MarkingFilteringConfig.java new file mode 100644 index 00000000000..d0197cacc1f --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/config/MarkingFilteringConfig.java @@ -0,0 +1,80 @@ +package io.openaev.config; + +import io.openaev.annotation.AllowRawJdbc; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import javax.sql.DataSource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Builds the {@link MarkedTables} the marking dimension filters against, from the live database + * schema: a table is markable when it holds a {@code marking_ids} array column. The schema is the + * source of truth for the same reason as {@link TenantFilteringConfig} — a hand-maintained mapping + * would silently drift from the migrations. + * + *

The array type is part of the test, not decoration. It is what distinguishes a marking column + * from a scalar column that merely shares the name, so a mistyped migration fails at startup rather + * than producing a predicate Postgres cannot plan. + * + *

The derived set is then narrowed to the activation allowlist ({@code + * openaev.marking.active-tables}), empty by default, so the dimension stays inert until a table is + * onboarded. + */ +@AllowRawJdbc(reason = "reads information_schema metadata only; no marked rows are accessed") +@Configuration +public class MarkingFilteringConfig { + + /** + * {@code data_type = 'ARRAY'} is how information_schema reports any array column; {@code + * udt_name} then carries the element type prefixed with an underscore, hence {@code _text} for + * {@code text[]}. Both are checked so a {@code marking_ids integer[]} is rejected too. + */ + private static final String MARKED_TABLE_QUERY = + "SELECT c.table_name FROM information_schema.columns c " + + "JOIN information_schema.tables t " + + " ON t.table_schema = c.table_schema AND t.table_name = c.table_name " + + "WHERE c.table_schema = current_schema() " + + " AND t.table_type = 'BASE TABLE' " + + " AND c.column_name = ? " + + " AND c.data_type = 'ARRAY' " + + " AND c.udt_name IN ('_text', '_varchar') " + + "ORDER BY c.table_name"; + + @Bean + public MarkedTables markedTables( + DataSource dataSource, + @Value("${openaev.marking.active-tables:}") List activeTables) { + List allowlist = activeTables.stream().filter(name -> !name.isBlank()).toList(); + return deriveFromSchema(dataSource).restrictTo(allowlist); + } + + @Bean + public MarkingDimension markingDimension(MarkedTables markedTables) { + return new MarkingDimension(markedTables); + } + + static MarkedTables deriveFromSchema(DataSource dataSource) { + Map marked = new LinkedHashMap<>(); + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(MARKED_TABLE_QUERY)) { + statement.setString(1, MarkedTable.MARKING_COLUMN); + try (ResultSet rows = statement.executeQuery()) { + while (rows.next()) { + String table = rows.getString("table_name").toLowerCase(Locale.ROOT); + marked.put(table, new MarkedTable(table)); + } + } + return new MarkedTables(marked); + } catch (SQLException e) { + throw new IllegalStateException("cannot derive marked tables from the schema", e); + } + } +} diff --git a/openaev-api/src/main/java/io/openaev/config/MarkingScopeResolver.java b/openaev-api/src/main/java/io/openaev/config/MarkingScopeResolver.java new file mode 100644 index 00000000000..6934548bd44 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/config/MarkingScopeResolver.java @@ -0,0 +1,94 @@ +package io.openaev.config; + +import io.openaev.context.MarkingCtx; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.TreeSet; +import org.springframework.stereotype.Component; + +/** + * Turns the markings a caller was granted into the clearance they hold. + * + *

This is the design trick that keeps the marking dimension as cheap as the tenant one (§2.2 of + * the tech design). Marking is an ordinal check — {@code TLP:AMBER} implies {@code + * TLP:GREEN} implies {@code TLP:CLEAR} — while tenant is a flat set-membership check. Rather + * than teach the SQL rewrite two comparison styles, the ordinality is collapsed here, in + * Java, once per request: take the highest order granted per type, then expand it back + * into every id of that type at or below it. What reaches the database is a flat set, so the + * predicate stays a plain containment test. + * + *

Per type, independently. Types are separate scales, so holding {@code TLP:RED} says + * nothing about {@code PAP}. A type the caller was granted nothing on contributes nothing — it does + * not silently grant that type's lowest level. + * + *

Pure function, no I/O: the rows come from {@code MarkingClearanceCacheManager}, which is the + * piece that must not open a Hibernate session. Deliberately mirrors {@link TenantScopeResolver}. + */ +@Component +public class MarkingScopeResolver { + + /** + * A marking definition reduced to what ordinality resolution needs. + * + * @param id the marking id, as stored in a row's {@code marking_ids} + * @param type the scale it belongs to (TLP, PAP, a custom one) + * @param order its rank within that scale; higher sees more + */ + public record MarkingRef(String id, String type, int order) { + public MarkingRef { + Objects.requireNonNull(id, "marking id must not be null"); + Objects.requireNonNull(type, "marking type must not be null"); + } + } + + /** + * @param grantedIds the marking ids the caller's groups grant, possibly empty (never null) + * @param tenantDefinitions every marking defined in the tenant in scope (never null) + * @param bypass whether the caller is admin or holds BYPASS + * @return the clearance to run the transaction under + */ + public MarkingCtx resolve( + Collection grantedIds, Collection tenantDefinitions, boolean bypass) { + Objects.requireNonNull(grantedIds, "grantedIds must not be null"); + Objects.requireNonNull(tenantDefinitions, "tenantDefinitions must not be null"); + + // A bypassing caller holds the whole tenant scale, whatever their groups say. Resolved into an + // explicit list here rather than passed on as MarkingCtx.all(): that intention belongs to the + // background primitive, and letting it reach the HTTP path would put a wildcard in the channel. + if (bypass) { + return MarkingCtx.forMarkings(sortedIds(tenantDefinitions, d -> true)); + } + + Map highestOrderPerType = new HashMap<>(); + for (MarkingRef definition : tenantDefinitions) { + if (grantedIds.contains(definition.id())) { + highestOrderPerType.merge(definition.type(), definition.order(), Math::max); + } + } + if (highestOrderPerType.isEmpty()) { + return MarkingCtx.none(); + } + + return MarkingCtx.forMarkings( + sortedIds( + tenantDefinitions, + definition -> { + Integer highest = highestOrderPerType.get(definition.type()); + return highest != null && definition.order() <= highest; + })); + } + + /** Sorted so the GUC value is deterministic, as {@link TenantScopeResolver} does for tenants. */ + private static TreeSet sortedIds( + Collection definitions, java.util.function.Predicate keep) { + TreeSet ids = new TreeSet<>(); + for (MarkingRef definition : definitions) { + if (keep.test(definition)) { + ids.add(definition.id()); + } + } + return ids; + } +} diff --git a/openaev-api/src/main/java/io/openaev/config/ScopeDimension.java b/openaev-api/src/main/java/io/openaev/config/ScopeDimension.java new file mode 100644 index 00000000000..a7f325375bd --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/config/ScopeDimension.java @@ -0,0 +1,60 @@ +package io.openaev.config; + +import java.util.Set; + +/** + * One scope dimension the {@link ScopeStatementInspector} filters on — today the tenant, tomorrow + * the marking clearance. A dimension answers two questions about a table: whether it is scoped at + * all, and which SQL predicate restricts it to the current transaction's scope. + * + *

The predicate is returned as a string rather than a parsed expression so a dimension is free + * to express itself as a plain function call ({@code can_access_tenant(t.tenant_id)}) or as a + * correlated sub-query, without depending on the parser's expression types. + * + *

Read and write predicates are distinct because a read may be more permissive than a write: a + * dual-scope tenant table lets platform rows through on a read but never on a write. + */ +public interface ScopeDimension { + + /** Short name used in the messages of refused statements, e.g. {@code tenant}. */ + String name(); + + /** + * Table names this dimension currently filters. Feeds the inspector's fast gate, so it must list + * every table whose statements need rewriting; an empty set makes the dimension inert. + */ + Set activeTables(); + + /** Whether this dimension scopes the given table. */ + boolean covers(String table); + + /** + * Predicate restricting a read of {@code table} (referenced as {@code alias}) to the current + * scope. Only called when {@link #covers(String)} is true. + */ + String readPredicate(String table, String alias); + + /** + * Predicate restricting a write to {@code table} (referenced as {@code alias}) to the current + * scope. Only called when {@link #covers(String)} is true. + */ + String writePredicate(String table, String alias); + + /** + * Column whose written value must be validated on an {@code INSERT ... SELECT} into a covered + * table, or {@code null} when the dimension does not guard writes by rewriting. Returning {@code + * null} means write attribution is enforced elsewhere (a service-layer validator), not that it is + * unguarded. + */ + default String writeAttributionColumn() { + return null; + } + + /** + * Predicate asserting that the value about to be written into {@link #writeAttributionColumn()} + * is in scope. Only called when the column is non-null. + */ + default String writeAttributionPredicate(String valueExpression) { + return null; + } +} diff --git a/openaev-api/src/main/java/io/openaev/config/ScopeFilteringConfig.java b/openaev-api/src/main/java/io/openaev/config/ScopeFilteringConfig.java new file mode 100644 index 00000000000..e8705cfbe12 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/config/ScopeFilteringConfig.java @@ -0,0 +1,39 @@ +package io.openaev.config; + +import java.util.List; +import org.hibernate.cfg.AvailableSettings; +import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Installs the single statement inspector Hibernate runs, composed of every scope dimension. + * + *

Hibernate accepts exactly one {@link AvailableSettings#STATEMENT_INSPECTOR}, so marking cannot + * be a second, independent inspector: it would silently displace tenant isolation. Both dimensions + * are therefore folded into one {@link ScopeStatementInspector}, which ANDs their predicates on the + * tables they both cover. + * + *

The dimensions are listed explicitly rather than collected from the context so their order — + * and hence the emitted SQL — is deterministic, and so that adding a dimension is a visible + * decision here rather than a side effect of declaring a bean. + */ +@Configuration +public class ScopeFilteringConfig { + + @Bean + public ScopeStatementInspector scopeStatementInspector( + TenantDimension tenantDimension, MarkingDimension markingDimension) { + return new ScopeStatementInspector(List.of(tenantDimension, markingDimension)); + } + + @Bean + public HibernatePropertiesCustomizer scopeStatementInspectorCustomizer( + ScopeStatementInspector inspector) { + // putIfAbsent, not put: a test that wires its own statement_inspector (the capture probe) keeps + // it; production sets none, so ours is installed. The trade-off is that any other inspector set + // ahead of ours would silently displace it; TenantFilteringConfigTest pins ours as the one + // Hibernate runs, so that regression fails the build rather than disabling isolation silently. + return properties -> properties.putIfAbsent(AvailableSettings.STATEMENT_INSPECTOR, inspector); + } +} diff --git a/openaev-api/src/main/java/io/openaev/config/ScopeStatementInspector.java b/openaev-api/src/main/java/io/openaev/config/ScopeStatementInspector.java new file mode 100644 index 00000000000..893ee10f111 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/config/ScopeStatementInspector.java @@ -0,0 +1,395 @@ +package io.openaev.config; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.operators.relational.ExpressionList; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Column; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.delete.Delete; +import net.sf.jsqlparser.statement.insert.ConflictActionType; +import net.sf.jsqlparser.statement.insert.Insert; +import net.sf.jsqlparser.statement.select.AllColumns; +import net.sf.jsqlparser.statement.select.FromItem; +import net.sf.jsqlparser.statement.select.Join; +import net.sf.jsqlparser.statement.select.LateralSubSelect; +import net.sf.jsqlparser.statement.select.ParenthesedFromItem; +import net.sf.jsqlparser.statement.select.ParenthesedSelect; +import net.sf.jsqlparser.statement.select.PlainSelect; +import net.sf.jsqlparser.statement.select.Select; +import net.sf.jsqlparser.statement.select.SelectItem; +import net.sf.jsqlparser.statement.select.TableFunction; +import net.sf.jsqlparser.statement.select.Values; +import net.sf.jsqlparser.statement.update.Update; +import net.sf.jsqlparser.util.TablesNamesFinder; +import org.hibernate.resource.jdbc.spi.StatementInspector; + +/** + * Rewrites SQL so that every access to a scoped table is filtered by the predicates of the active + * {@link ScopeDimension}s, keeping a transaction limited to the scope carried by its session + * variables. With the tenant dimension alone this restricts statements to {@code + * app.current_tenants}; a second dimension (marking clearance) ANDs its own predicate onto the same + * tables. + * + *

Security principle: every reference to a scoped table must be filtered. SELECT tables + * (FROM, joins, sub-queries, CTEs) are wrapped in a filtered sub-query; the target of an UPDATE or + * DELETE gets the filter added to its WHERE (a written table cannot be wrapped). Completeness comes + * from visiting every select; a statement, FROM or join shape that is not understood is rejected + * (fail-closed) rather than passed through unfiltered, which would leak rows across scopes. + * + *

Refusal messages still read "tenant filtering": they are kept verbatim from the tenant-only + * inspector this class generalizes, so the extraction is provably behaviour-preserving. They become + * scope-generic when a second dimension is activated. + */ +public class ScopeStatementInspector implements StatementInspector { + + private final List dimensions; + private final Pattern activeTablePattern; + + public ScopeStatementInspector(List dimensions) { + this.dimensions = List.copyOf(dimensions); + this.activeTablePattern = buildActiveTablePattern(this.dimensions); + } + + /** + * Matches any active table name on identifier boundaries (so {@code asset} does not match inside + * {@code asset_groups}). Null when no table is active, which keeps the inspector inert. Hibernate + * always emits the physical table name, so a statement touching an active table always contains + * that name, which is what lets the gate below skip everything else without missing a row. + */ + private static Pattern buildActiveTablePattern(List dimensions) { + Set names = new HashSet<>(); + for (ScopeDimension dimension : dimensions) { + names.addAll(dimension.activeTables()); + } + if (names.isEmpty()) { + return null; + } + String alternation = names.stream().map(Pattern::quote).collect(Collectors.joining("|")); + return Pattern.compile( + "(? not yet covered"); + } + filterContainedSelects(update); + // The FROM read sources are wrapped in a filtered sub-query, exactly like a SELECT's FROM. + if (update.getFromItem() != null) { + update.setFromItem(filterFromItem(update.getFromItem())); + } + if (update.getJoins() != null) { + for (Join join : update.getJoins()) { + join.setRightItem(filterFromItem(join.getRightItem())); + } + } + update.setWhere(combineScopeFilter(update.getTable(), update.getWhere(), false)); + return update.toString(); + } + + private String rewriteDelete(Delete delete) { + // Multi-target delete or an explicit join is a shape we do not cover yet. + if (notEmpty(delete.getTables()) || notEmpty(delete.getJoins())) { + throw new TenantFilteringException("DELETE ... not yet covered"); + } + filterContainedSelects(delete); + // The USING list is typed List

, so it cannot be wrapped in a sub-query like a FROM; each + // scoped read source is filtered through the WHERE instead. + Expression where = combineScopeFilter(delete.getTable(), delete.getWhere(), false); + if (delete.getUsingList() != null) { + for (Table using : delete.getUsingList()) { + where = combineScopeFilter(using, where, true); + } + } + delete.setWhere(where); + return delete.toString(); + } + + private String rewriteInsert(Insert insert) { + // An ON CONFLICT DO UPDATE could touch an existing, possibly out-of-scope, row on conflict. It + // is guarded the same way as an UPDATE: the scope predicates are added to the DO UPDATE WHERE, + // so a conflicting row outside the scope is left untouched. DO NOTHING needs no guard. + var conflict = insert.getConflictAction(); + if (conflict != null + && conflict.getConflictActionType() == ConflictActionType.DO_UPDATE + && insert.getTable() != null + && isCovered(insert.getTable())) { + conflict.setWhereExpression( + combineScopeFilter(insert.getTable(), conflict.getWhereExpression(), false)); + } + // An INSERT ... SELECT into a scoped table must write only in-scope values; the written scope + // column is validated against the scope. VALUES inserts cannot be distinguished from + // ORM-generated ones at the SQL level, so their scope assignment stays an application concern. + Select source = insert.getSelect(); + if (insert.getTable() != null && source != null && !(source instanceof Values)) { + for (ScopeDimension dimension : dimensions) { + if (dimension.covers(insert.getTable().getName()) + && dimension.writeAttributionColumn() != null) { + validateInsertSelectScope(insert, source, dimension); + } + } + } + // The SELECT source (and any sub-query) is read-filtered like any select. + filterContainedSelects(insert); + return insert.toString(); + } + + /** + * Adds the dimension's attribution predicate on the written scope column to the source SELECT of + * an {@code INSERT ... SELECT} into a covered table, so only rows whose scope column is in scope + * are inserted (a write, so no permissive flag). Anything that cannot be mapped to a single + * projected expression (no column list, no scope column, a {@code SELECT *}, or a non-plain + * source) is refused, which also refuses an omitted scope column (a platform-row write). + */ + private void validateInsertSelectScope(Insert insert, Select select, ScopeDimension dimension) { + String scopeColumn = dimension.writeAttributionColumn(); + ExpressionList columns = insert.getColumns(); + if (!(select instanceof PlainSelect source) || columns == null) { + throw new TenantFilteringException( + "INSERT ... SELECT into a " + + dimension.name() + + " table needs an explicit column list and a plain SELECT"); + } + int scopeIdx = -1; + for (int i = 0; i < columns.size(); i++) { + String name = columns.get(i).getColumnName(); + if (name != null && name.replace("\"", "").equalsIgnoreCase(scopeColumn)) { + scopeIdx = i; + break; + } + } + if (scopeIdx < 0) { + throw new TenantFilteringException( + "INSERT ... SELECT into a " + + dimension.name() + + " table must set " + + scopeColumn + + " in scope"); + } + List> items = source.getSelectItems(); + if (items == null || scopeIdx >= items.size()) { + throw new TenantFilteringException( + "INSERT ... SELECT: " + scopeColumn + " cannot be mapped to a projected expression"); + } + for (SelectItem item : items) { + if (item.getExpression() instanceof AllColumns) { + throw new TenantFilteringException( + "INSERT ... SELECT * into a " + + dimension.name() + + " table cannot validate the written " + + scopeColumn); + } + } + Expression scopeExpr = items.get(scopeIdx).getExpression(); + // The expression is referenced a second time in the WHERE. A bind parameter cannot be: the + // inspector must not change the placeholder count, or positional binding breaks. Refuse it. + if (scopeExpr.toString().contains("?")) { + throw new TenantFilteringException( + "INSERT ... SELECT: a bind-parameter " + + scopeColumn + + " cannot be validated by rewriting"); + } + source.setWhere( + combineCall(source.getWhere(), dimension.writeAttributionPredicate(scopeExpr.toString()))); + } + + /** + * ANDs a scope predicate into an existing WHERE, or returns it alone. Explicit parentheses keep + * precedence when the existing WHERE is an OR; a WHERE we cannot re-parse is refused + * (fail-closed) rather than left unfiltered. + */ + private Expression combineCall(Expression existing, String call) { + String combined = existing == null ? call : "(" + existing + ") AND (" + call + ")"; + try { + return CCJSqlParserUtil.parseCondExpression(combined); + } catch (Exception e) { + throw new TenantFilteringException("could not add the tenant filter to the WHERE clause", e); + } + } + + /** Wraps the FROM and join tables of every select contained in the statement. */ + private void filterContainedSelects(Statement statement) { + PlainSelectCollector collector = new PlainSelectCollector(); + collector.getTables(statement); + for (PlainSelect plainSelect : collector.collected) { + filterTables(plainSelect); + } + } + + /** Wraps the FROM and join scoped tables of a single select level. */ + private void filterTables(PlainSelect select) { + if (select.getFromItem() != null) { + select.setFromItem(filterFromItem(select.getFromItem())); + } + if (select.getJoins() != null) { + for (Join join : select.getJoins()) { + join.setRightItem(filterFromItem(join.getRightItem())); + } + } + } + + /** + * Wraps a scoped table in a filtered sub-query. Unscoped tables and nested sub-queries (filtered + * on their own, as their own select) are returned unchanged; any other shape is rejected. + */ + private FromItem filterFromItem(FromItem item) { + // Sub-selects and lateral sub-selects are never real tables; they do not need scope + // filtering. A LATERAL table function (e.g. "LEFT JOIN LATERAL jsonb_array_elements(...)") + // unnests a column of the row already being joined, never a whole table, so it is safe too. + // A non-lateral table function (e.g. "CROSS JOIN generate_series(1, 10)") is NOT unnesting an + // existing row and is not a shape this rewriter has reviewed; it stays rejected. + if (item instanceof ParenthesedSelect || item instanceof LateralSubSelect) { + return item; + } + if (item instanceof TableFunction tableFunction + && "LATERAL".equalsIgnoreCase(tableFunction.getPrefix())) { + return item; + } + if (item instanceof ParenthesedFromItem group) { + // A parenthesized join group: filter its inner FROM and joins like any other level. + group.setFromItem(filterFromItem(group.getFromItem())); + if (group.getJoins() != null) { + for (Join join : group.getJoins()) { + join.setRightItem(filterFromItem(join.getRightItem())); + } + } + return group; + } + if (!(item instanceof Table table)) { + throw new TenantFilteringException( + "FROM/JOIN shape not yet covered by tenant filtering: " + + (item == null ? "null" : item.getClass().getSimpleName())); + } + String ref = reference(table); + String predicates = scopePredicates(table, ref, true); + if (predicates == null) { + return table; + } + String wrapped = + "(SELECT * FROM " + + table.getName() + + " " + + ref + + " WHERE " + + predicates + + ")" + + " AS " + + ref; + Statement dummy; + try { + dummy = CCJSqlParserUtil.parse("SELECT * FROM " + wrapped); + } catch (Exception e) { + throw new IllegalStateException("failed to build tenant-filtered subquery", e); + } + // wrapped is always a plain SELECT, but guard the cast: a non-plain result fails with a clear + // message instead of a raw ClassCastException (fail-closed by the inspector either way). + if (dummy instanceof PlainSelect plain) { + return plain.getFromItem(); + } + throw new IllegalStateException( + "expected a plain select when building the tenant-filtered subquery, got " + + dummy.getClass().getSimpleName()); + } + + /** + * Adds the scope predicates of a table to a WHERE clause (the table itself cannot be wrapped). A + * read on a dual-scope table also lets platform rows through; a write never does, so the + * read/write distinction is delegated to each dimension. + */ + private Expression combineScopeFilter(Table table, Expression existing, boolean read) { + if (table == null) { + return existing; + } + String predicates = scopePredicates(table, reference(table), read); + return predicates == null ? existing : combineCall(existing, predicates); + } + + /** + * ANDs one predicate per dimension covering the table, or null when no dimension does. With a + * single active dimension the result is that dimension's predicate verbatim, so activating a + * second dimension is the only thing that changes the emitted SQL. + */ + private String scopePredicates(Table table, String alias, boolean read) { + String name = table.getName(); + List predicates = new ArrayList<>(); + for (ScopeDimension dimension : dimensions) { + if (dimension.covers(name)) { + predicates.add( + read ? dimension.readPredicate(name, alias) : dimension.writePredicate(name, alias)); + } + } + return predicates.isEmpty() ? null : String.join(" AND ", predicates); + } + + private boolean isCovered(Table table) { + return dimensions.stream().anyMatch(dimension -> dimension.covers(table.getName())); + } + + private static String reference(Table table) { + return table.getAlias() != null ? table.getAlias().getName() : table.getName(); + } + + private static boolean notEmpty(List list) { + return list != null && !list.isEmpty(); + } + + /** + * Collects every {@link PlainSelect} in the statement — top-level and nested — so each one's FROM + * and joins can be filtered. Relies on the finder visiting every select node. + */ + private static final class PlainSelectCollector extends TablesNamesFinder { + private final List collected = new ArrayList<>(); + + @Override + public Void visit(PlainSelect plainSelect, S context) { + collected.add(plainSelect); + return super.visit(plainSelect, context); + } + } +} diff --git a/openaev-api/src/main/java/io/openaev/config/TenantDimension.java b/openaev-api/src/main/java/io/openaev/config/TenantDimension.java new file mode 100644 index 00000000000..f6e93d60595 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/config/TenantDimension.java @@ -0,0 +1,59 @@ +package io.openaev.config; + +import java.util.HashSet; +import java.util.Set; + +/** + * The tenant scope dimension: restricts every covered table to the tenants in the transaction's + * {@code app.current_tenants} scope, through the {@code can_access_tenant} SQL function. + * + *

A read on a dual-scope table also lets platform rows through ({@code allow_platform}); a write + * never does, pending the platform-write policy. + */ +public final class TenantDimension implements ScopeDimension { + + private final TenantTables tables; + + public TenantDimension(TenantTables tables) { + this.tables = tables; + } + + @Override + public String name() { + return "tenant"; + } + + @Override + public Set activeTables() { + Set names = new HashSet<>(tables.strict()); + names.addAll(tables.dualScope()); + return names; + } + + @Override + public boolean covers(String table) { + return tables.family(table) != TenantTables.Family.NONE; + } + + @Override + public String readPredicate(String table, String alias) { + return tables.family(table) == TenantTables.Family.DUAL + ? "can_access_tenant(" + alias + ".tenant_id, true)" + : "can_access_tenant(" + alias + ".tenant_id)"; + } + + @Override + public String writePredicate(String table, String alias) { + return "can_access_tenant(" + alias + ".tenant_id)"; + } + + @Override + public String writeAttributionColumn() { + return "tenant_id"; + } + + @Override + public String writeAttributionPredicate(String valueExpression) { + return "can_access_tenant(" + valueExpression + ")"; + } +} diff --git a/openaev-api/src/main/java/io/openaev/config/TenantFilteringConfig.java b/openaev-api/src/main/java/io/openaev/config/TenantFilteringConfig.java index d6d619ecf78..45a1313f781 100644 --- a/openaev-api/src/main/java/io/openaev/config/TenantFilteringConfig.java +++ b/openaev-api/src/main/java/io/openaev/config/TenantFilteringConfig.java @@ -9,9 +9,7 @@ import java.util.List; import java.util.Set; import javax.sql.DataSource; -import org.hibernate.cfg.AvailableSettings; import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -22,7 +20,8 @@ * is the source of truth so the set is complete: it covers join and link tables that carry a {@code * tenant_id} but have no entity class, which an entity-model scan would miss. The set is then * narrowed to the activation allowlist ({@code openaev.tenant.active-tables}), empty by default, so - * the inspector stays inert until a table is onboarded. + * the inspector stays inert until a table is onboarded. The inspector itself is assembled from this + * dimension and the marking one by {@link ScopeFilteringConfig}. */ @AllowRawJdbc(reason = "reads information_schema metadata only; no tenant rows are accessed") @Configuration @@ -40,18 +39,8 @@ public TenantTables tenantTables( } @Bean - public TenantStatementInspector tenantStatementInspector(TenantTables tenantTables) { - return new TenantStatementInspector(tenantTables); - } - - @Bean - public HibernatePropertiesCustomizer tenantStatementInspectorCustomizer( - TenantStatementInspector inspector) { - // putIfAbsent, not put: a test that wires its own statement_inspector (the capture probe) keeps - // it; production sets none, so ours is installed. The trade-off is that any other inspector set - // ahead of ours would silently displace it; TenantFilteringConfigTest pins ours as the one - // Hibernate runs, so that regression fails the build rather than disabling isolation silently. - return properties -> properties.putIfAbsent(AvailableSettings.STATEMENT_INSPECTOR, inspector); + public TenantDimension tenantDimension(TenantTables tenantTables) { + return new TenantDimension(tenantTables); } static TenantTables deriveFromSchema(DataSource dataSource) { diff --git a/openaev-api/src/main/java/io/openaev/config/TenantStatementInspector.java b/openaev-api/src/main/java/io/openaev/config/TenantStatementInspector.java index c93ca9910ce..ec6bcbaebe6 100644 --- a/openaev-api/src/main/java/io/openaev/config/TenantStatementInspector.java +++ b/openaev-api/src/main/java/io/openaev/config/TenantStatementInspector.java @@ -1,369 +1,19 @@ package io.openaev.config; -import java.util.ArrayList; -import java.util.HashSet; import java.util.List; -import java.util.Set; -import java.util.regex.Pattern; -import java.util.stream.Collectors; -import net.sf.jsqlparser.expression.Expression; -import net.sf.jsqlparser.expression.operators.relational.ExpressionList; -import net.sf.jsqlparser.parser.CCJSqlParserUtil; -import net.sf.jsqlparser.schema.Column; -import net.sf.jsqlparser.schema.Table; -import net.sf.jsqlparser.statement.Statement; -import net.sf.jsqlparser.statement.delete.Delete; -import net.sf.jsqlparser.statement.insert.ConflictActionType; -import net.sf.jsqlparser.statement.insert.Insert; -import net.sf.jsqlparser.statement.select.AllColumns; -import net.sf.jsqlparser.statement.select.FromItem; -import net.sf.jsqlparser.statement.select.Join; -import net.sf.jsqlparser.statement.select.LateralSubSelect; -import net.sf.jsqlparser.statement.select.ParenthesedFromItem; -import net.sf.jsqlparser.statement.select.ParenthesedSelect; -import net.sf.jsqlparser.statement.select.PlainSelect; -import net.sf.jsqlparser.statement.select.Select; -import net.sf.jsqlparser.statement.select.SelectItem; -import net.sf.jsqlparser.statement.select.TableFunction; -import net.sf.jsqlparser.statement.select.Values; -import net.sf.jsqlparser.statement.update.Update; -import net.sf.jsqlparser.util.TablesNamesFinder; -import org.hibernate.resource.jdbc.spi.StatementInspector; /** - * Rewrites SQL so that every access to a tenant-scoped table is filtered by {@code - * can_access_tenant}, keeping a transaction limited to the tenants in its {@code - * app.current_tenants} scope. + * The tenant-only configuration of {@link ScopeStatementInspector}: rewrites SQL so that every + * access to a tenant-scoped table is filtered by {@code can_access_tenant}, keeping a transaction + * limited to the tenants in its {@code app.current_tenants} scope. * - *

Security principle: every reference to a tenant-aware table must be filtered. SELECT - * tables (FROM, joins, sub-queries, CTEs) are wrapped in a filtered sub-query; the target of an - * UPDATE or DELETE gets the filter added to its WHERE (a written table cannot be wrapped). - * Completeness comes from visiting every select; a statement, FROM or join shape that is not - * understood is rejected (fail-closed) rather than passed through unfiltered, which would leak rows - * across tenants. + *

All the rewriting lives in the generic inspector; this type only binds it to the single {@link + * TenantDimension}. It stays the injected bean type so tenant isolation keeps a name of its own in + * the wiring and in the tests that pin it as the inspector Hibernate runs. */ -public class TenantStatementInspector implements StatementInspector { - - private final TenantTables tables; - private final Pattern activeTablePattern; +public class TenantStatementInspector extends ScopeStatementInspector { public TenantStatementInspector(TenantTables tables) { - this.tables = tables; - this.activeTablePattern = buildActiveTablePattern(tables); - } - - /** - * Matches any active table name on identifier boundaries (so {@code asset} does not match inside - * {@code asset_groups}). Null when no table is active, which keeps the inspector inert. Hibernate - * always emits the physical table name, so a statement touching an active table always contains - * that name, which is what lets the gate below skip everything else without missing a row. - */ - private static Pattern buildActiveTablePattern(TenantTables tables) { - Set names = new HashSet<>(); - names.addAll(tables.strict()); - names.addAll(tables.dualScope()); - if (names.isEmpty()) { - return null; - } - String alternation = names.stream().map(Pattern::quote).collect(Collectors.joining("|")); - return Pattern.compile( - "(? not yet covered"); - } - filterContainedSelects(update); - // The FROM read sources are wrapped in a filtered sub-query, exactly like a SELECT's FROM. - if (update.getFromItem() != null) { - update.setFromItem(filterFromItem(update.getFromItem())); - } - if (update.getJoins() != null) { - for (Join join : update.getJoins()) { - join.setRightItem(filterFromItem(join.getRightItem())); - } - } - update.setWhere(withTenantPredicate(update.getTable(), update.getWhere())); - return update.toString(); - } - - private String rewriteDelete(Delete delete) { - // Multi-target delete or an explicit join is a shape we do not cover yet. - if (notEmpty(delete.getTables()) || notEmpty(delete.getJoins())) { - throw new TenantFilteringException("DELETE ... not yet covered"); - } - filterContainedSelects(delete); - // The USING list is typed List

, so it cannot be wrapped in a sub-query like a FROM; each - // tenant read source is filtered through the WHERE instead. - Expression where = withTenantPredicate(delete.getTable(), delete.getWhere()); - if (delete.getUsingList() != null) { - for (Table using : delete.getUsingList()) { - where = combineTenantFilter(using, where, true); - } - } - delete.setWhere(where); - return delete.toString(); - } - - private String rewriteInsert(Insert insert) { - // An ON CONFLICT DO UPDATE could touch an existing, possibly cross-tenant, row on conflict. It - // is guarded the same way as an UPDATE: can_access_tenant is added to the DO UPDATE WHERE, so a - // conflicting row outside the scope is left untouched. DO NOTHING needs no guard. - var conflict = insert.getConflictAction(); - if (conflict != null - && conflict.getConflictActionType() == ConflictActionType.DO_UPDATE - && insert.getTable() != null - && tables.family(insert.getTable().getName()) != TenantTables.Family.NONE) { - conflict.setWhereExpression( - withTenantPredicate(insert.getTable(), conflict.getWhereExpression())); - } - // An INSERT ... SELECT into a tenant table must write only in-scope tenant_id values; the - // inserted tenant_id is validated against the scope. VALUES inserts cannot be distinguished - // from - // ORM-generated ones at the SQL level, so their tenant assignment stays an application concern. - Select source = insert.getSelect(); - if (insert.getTable() != null - && tables.family(insert.getTable().getName()) != TenantTables.Family.NONE - && source != null - && !(source instanceof Values)) { - validateInsertSelectTenant(insert, source); - } - // The SELECT source (and any sub-query) is read-filtered like any select. - filterContainedSelects(insert); - return insert.toString(); - } - - /** - * Adds {@code can_access_tenant} on the written {@code tenant_id} to the source SELECT of an - * {@code INSERT ... SELECT} into a tenant table, so only rows whose tenant_id is in scope are - * inserted (a write, so no {@code allow_platform}). Anything that cannot be mapped to a single - * projected expression (no column list, no {@code tenant_id} column, a {@code SELECT *}, or a - * non-plain source) is refused, which also refuses an omitted tenant_id (a platform-row write). - */ - private void validateInsertSelectTenant(Insert insert, Select select) { - ExpressionList columns = insert.getColumns(); - if (!(select instanceof PlainSelect source) || columns == null) { - throw new TenantFilteringException( - "INSERT ... SELECT into a tenant table needs an explicit column list and a plain SELECT"); - } - int tenantIdx = -1; - for (int i = 0; i < columns.size(); i++) { - String name = columns.get(i).getColumnName(); - if (name != null && name.replace("\"", "").equalsIgnoreCase("tenant_id")) { - tenantIdx = i; - break; - } - } - if (tenantIdx < 0) { - throw new TenantFilteringException( - "INSERT ... SELECT into a tenant table must set tenant_id in scope"); - } - List> items = source.getSelectItems(); - if (items == null || tenantIdx >= items.size()) { - throw new TenantFilteringException( - "INSERT ... SELECT: tenant_id cannot be mapped to a projected expression"); - } - for (SelectItem item : items) { - if (item.getExpression() instanceof AllColumns) { - throw new TenantFilteringException( - "INSERT ... SELECT * into a tenant table cannot validate the written tenant_id"); - } - } - Expression tenantExpr = items.get(tenantIdx).getExpression(); - // The expression is referenced a second time in the WHERE. A bind parameter cannot be: the - // inspector must not change the placeholder count, or positional binding breaks. Refuse it. - if (tenantExpr.toString().contains("?")) { - throw new TenantFilteringException( - "INSERT ... SELECT: a bind-parameter tenant_id cannot be validated by rewriting"); - } - source.setWhere(combineCall(source.getWhere(), "can_access_tenant(" + tenantExpr + ")")); - } - - /** - * ANDs a {@code can_access_tenant(...)} call into an existing WHERE, or returns it alone. - * Explicit parentheses keep precedence when the existing WHERE is an OR; a WHERE we cannot - * re-parse is refused (fail-closed) rather than left unfiltered. - */ - private Expression combineCall(Expression existing, String call) { - String combined = existing == null ? call : "(" + existing + ") AND (" + call + ")"; - try { - return CCJSqlParserUtil.parseCondExpression(combined); - } catch (Exception e) { - throw new TenantFilteringException("could not add the tenant filter to the WHERE clause", e); - } - } - - /** Wraps the FROM and join tables of every select contained in the statement. */ - private void filterContainedSelects(Statement statement) { - PlainSelectCollector collector = new PlainSelectCollector(); - collector.getTables(statement); - for (PlainSelect plainSelect : collector.collected) { - filterTables(plainSelect); - } - } - - /** Wraps the FROM and join tenant tables of a single select level. */ - private void filterTables(PlainSelect select) { - if (select.getFromItem() != null) { - select.setFromItem(filterFromItem(select.getFromItem())); - } - if (select.getJoins() != null) { - for (Join join : select.getJoins()) { - join.setRightItem(filterFromItem(join.getRightItem())); - } - } - } - - /** - * Wraps a tenant-aware table in a filtered sub-query. Non-tenant tables and nested sub-queries - * (filtered on their own, as their own select) are returned unchanged; any other shape is - * rejected. - */ - private FromItem filterFromItem(FromItem item) { - // Sub-selects and lateral sub-selects are never real tables; they do not need tenant - // filtering. A LATERAL table function (e.g. "LEFT JOIN LATERAL jsonb_array_elements(...)") - // unnests a column of the row already being joined, never a whole table, so it is safe too. - // A non-lateral table function (e.g. "CROSS JOIN generate_series(1, 10)") is NOT unnesting an - // existing row and is not a shape this rewriter has reviewed; it stays rejected. - if (item instanceof ParenthesedSelect || item instanceof LateralSubSelect) { - return item; - } - if (item instanceof TableFunction tableFunction - && "LATERAL".equalsIgnoreCase(tableFunction.getPrefix())) { - return item; - } - if (item instanceof ParenthesedFromItem group) { - // A parenthesized join group: filter its inner FROM and joins like any other level. - group.setFromItem(filterFromItem(group.getFromItem())); - if (group.getJoins() != null) { - for (Join join : group.getJoins()) { - join.setRightItem(filterFromItem(join.getRightItem())); - } - } - return group; - } - if (!(item instanceof Table table)) { - throw new TenantFilteringException( - "FROM/JOIN shape not yet covered by tenant filtering: " - + (item == null ? "null" : item.getClass().getSimpleName())); - } - if (tables.family(table.getName()) == TenantTables.Family.NONE) { - return table; - } - String ref = reference(table); - String wrapped = - "(SELECT * FROM " - + table.getName() - + " " - + ref - + " WHERE " - + tenantCall(table, true) - + ")" - + " AS " - + ref; - Statement dummy; - try { - dummy = CCJSqlParserUtil.parse("SELECT * FROM " + wrapped); - } catch (Exception e) { - throw new IllegalStateException("failed to build tenant-filtered subquery", e); - } - // wrapped is always a plain SELECT, but guard the cast: a non-plain result fails with a clear - // message instead of a raw ClassCastException (fail-closed by the inspector either way). - if (dummy instanceof PlainSelect plain) { - return plain.getFromItem(); - } - throw new IllegalStateException( - "expected a plain select when building the tenant-filtered subquery, got " - + dummy.getClass().getSimpleName()); - } - - /** - * Adds {@code can_access_tenant} on the written table to a WHERE clause (the table itself cannot - * be wrapped). A write never reaches platform rows from a tenant scope — {@code allow_platform} - * is not passed — pending the platform-write policy. - */ - private Expression withTenantPredicate(Table target, Expression existing) { - return combineTenantFilter(target, existing, false); - } - - /** - * Adds {@code can_access_tenant} on a table to a WHERE clause (the table itself cannot be - * wrapped). A read on a dual-scope table also lets platform rows through; a write never does, so - * {@code allow_platform} is passed only for read sources. - */ - private Expression combineTenantFilter(Table table, Expression existing, boolean read) { - if (table == null || tables.family(table.getName()) == TenantTables.Family.NONE) { - return existing; - } - return combineCall(existing, tenantCall(table, read)); - } - - private String tenantCall(Table table, boolean read) { - String ref = reference(table); - return read && tables.family(table.getName()) == TenantTables.Family.DUAL - ? "can_access_tenant(" + ref + ".tenant_id, true)" - : "can_access_tenant(" + ref + ".tenant_id)"; - } - - private static String reference(Table table) { - return table.getAlias() != null ? table.getAlias().getName() : table.getName(); - } - - private static boolean notEmpty(List list) { - return list != null && !list.isEmpty(); - } - - /** - * Collects every {@link PlainSelect} in the statement — top-level and nested — so each one's FROM - * and joins can be filtered. Relies on the finder visiting every select node. - */ - private static final class PlainSelectCollector extends TablesNamesFinder { - private final List collected = new ArrayList<>(); - - @Override - public Void visit(PlainSelect plainSelect, S context) { - collected.add(plainSelect); - return super.visit(plainSelect, context); - } + super(List.of(new TenantDimension(tables))); } } diff --git a/openaev-api/src/main/java/io/openaev/config/cache/MarkingClearanceCacheManager.java b/openaev-api/src/main/java/io/openaev/config/cache/MarkingClearanceCacheManager.java new file mode 100644 index 00000000000..ccd273e5d60 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/config/cache/MarkingClearanceCacheManager.java @@ -0,0 +1,172 @@ +package io.openaev.config.cache; + +import io.openaev.annotation.AllowRawJdbc; +import io.openaev.config.MarkingScopeResolver; +import io.openaev.config.MarkingScopeResolver.MarkingRef; +import io.openaev.context.MarkingCtx; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.Caching; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * Resolves and caches a caller's marking clearance for a tenant. + * + *

Why JDBC and not a repository. This runs during request-argument resolution, which + * happens before the {@code @Transactional} interceptor. Hibernate's default connection + * handling releases a connection when the transaction completes; with no transaction there is no + * such event, so the connection is held until the open-in-view session closes — the end of the + * request. {@link TenantMembershipCacheManager} documents the same constraint for the tenant + * dimension, and it is not theoretical: it is what trips {@code + * spring.datasource.hikari.leak-detection-threshold}. {@code JdbcTemplate} borrows and returns per + * statement. + * + *

🔴 Eviction is a correctness requirement, not an optimisation. {@code + * is_marking_set_allowed} is pure set containment against the GUC — it never consults {@code + * marking_definitions}. So a stale clearance that is larger than the current data justifies + * grants access to rows that should now be hidden: it fails open. Every reduction must evict + * — user removed from a group, marking unassigned from a group, group deleted, marking archived or + * deleted, marking order lowered. The 5-minute TTL bounds the damage; it does not prevent it. + */ +@Service +@RequiredArgsConstructor +@AllowRawJdbc( + reason = + "scope-bootstrap read, in the same category as AutonomousRunTenantLocator: marking-scope" + + " resolution runs during argument resolution, before any transaction and therefore" + + " before a tenant scope exists, so the statement inspector cannot serve it. Hibernate" + + " is unusable here for a second reason: with no transaction to release at, an ORM" + + " query would pin the pool connection until the open-in-view session closes at the" + + " end of the request (see TenantMembershipCacheManager, and" + + " spring.datasource.hikari.leak-detection-threshold). marking_definitions IS a" + + " tenant-active table, so both queries bind tenant_id explicitly and are read-only;" + + " they return marking ids and ordering metadata only, never row payloads. The" + + " explicit bind is what replaces the inspector here and is pinned by" + + " MarkingClearanceCacheManagerTest.") +public class MarkingClearanceCacheManager { + + static final String MARKING_CLEARANCE_CACHE = "markingClearance"; + + /** + * The marking ids the user's groups grant, in the given tenant. Bound to the tenant on the + * definition side: a group is platform-wide, but a marking belongs to exactly one tenant, so this + * cannot leak a grant across tenants. + */ + static final String GRANTED_MARKING_IDS_SQL = + "select gm.marking_id from groups_markings gm" + + " join users_groups ug on ug.group_id = gm.group_id" + + " join marking_definitions md on md.marking_id = gm.marking_id" + + " where ug.user_id = ? and md.tenant_id = ?"; + + /** Every marking defined in the tenant — the scale the granted ids are expanded against. */ + static final String TENANT_MARKINGS_SQL = + "select marking_id, marking_type, marking_order from marking_definitions" + + " where tenant_id = ?"; + + private final JdbcTemplate jdbcTemplate; + private final MarkingScopeResolver resolver; + private final CacheManager cacheManager; + private final TenantMembershipCacheManager tenantMembershipCacheManager; + + /** + * Returns the clearance the user holds in the tenant (cached). + * + * @param bypass whether the caller is admin or holds BYPASS; part of the cache key because it + * changes the answer, and a user can gain or lose it + */ + @Cacheable(value = MARKING_CLEARANCE_CACHE, key = "#userId + ':' + #tenantId + ':' + #bypass") + public MarkingCtx findClearance(String userId, String tenantId, boolean bypass) { + // Intentionally JDBC here: this code runs during argument resolution (before @Transactional), + // so ORM reads may hold the pooled connection until request end (open-in-view). + List definitions = + jdbcTemplate.query( + TENANT_MARKINGS_SQL, + (rs, rowNum) -> + new MarkingRef( + rs.getString("marking_id"), + rs.getString("marking_type"), + rs.getInt("marking_order")), + tenantId); + + // Skipped for a bypassing caller: the grants cannot change the answer, so the query is waste. + Set granted = + bypass + ? Set.of() + : Set.copyOf( + jdbcTemplate.queryForList(GRANTED_MARKING_IDS_SQL, String.class, userId, tenantId)); + + return resolver.resolve(granted, definitions, bypass); + } + + /** + * Evicts one user's clearance in one tenant, both with and without bypass. + * + *

Use when the change is scoped to a single user — added to or removed from a group, granted + * or stripped of BYPASS. Both variants are dropped deliberately: making the caller name the right + * one would let a stale larger entry survive under the other, and that entry fails open. + * The caller usually cannot know which variant is warm, and should not have to. + */ + @Caching( + evict = { + @CacheEvict(value = MARKING_CLEARANCE_CACHE, key = "#userId + ':' + #tenantId + ':false'"), + @CacheEvict(value = MARKING_CLEARANCE_CACHE, key = "#userId + ':' + #tenantId + ':true'") + }) + public void evict(String userId, String tenantId) { + // eviction only + } + + /** + * Evicts every cached clearance the user holds, in every tenant. + * + *

This — not {@link #evict(String, String)} — is what a group membership change needs. A + * {@code Group} is dual-scope: a platform group ({@code tenant_id IS NULL}) can grant markings in + * many tenants at once, and {@code users_groups} carries no tenant of its own. So dropping a user + * from a group reduces their clearance in every tenant that group grants into, and + * evicting a single tenant would leave the rest stale — fail-open, which is the case eviction + * exists to prevent. + * + *

Tenants are read through {@link TenantMembershipCacheManager#findTenantIdsByUserId} (itself + * cached, so this is normally free). A tenant missing from that list cannot be reached by the + * user anyway: tenant isolation would deny them a scope there before marking was ever consulted. + * + *

Keys are dropped through {@link CacheManager} rather than by calling {@link #evict} in a + * loop, because that would be a self-invocation and would silently skip the cache interceptor — + * the same reason {@link TenantMembershipCacheManager#evictForUser} does it this way. + */ + public void evictForUser(String userId) { + Cache cache = cacheManager.getCache(MARKING_CLEARANCE_CACHE); + if (cache == null) { + return; + } + for (String tenantId : tenantMembershipCacheManager.findTenantIdsByUserId(userId)) { + cache.evict(userId + ":" + tenantId + ":false"); + cache.evict(userId + ":" + tenantId + ":true"); + } + } + + /** Convenience for the group paths, where a single change touches every member at once. */ + public void evictForUsers(Collection userIds) { + userIds.forEach(this::evictForUser); + } + + /** + * Evicts every cached clearance. + * + *

Blunt on purpose. The changes that matter — a marking unassigned from a group, a group + * deleted, a definition archived, an order lowered — reduce the clearance of an unbounded set of + * users, and the mapping from the change to that set is itself a query. Since a stale entry fails + * open, over-evicting costs one JDBC round trip per affected user while under-evicting is a + * disclosure. Narrow this only with a test that pins which users each change reaches. + */ + @CacheEvict(value = MARKING_CLEARANCE_CACHE, allEntries = true) + public void evictAll() { + // eviction only + } +} diff --git a/openaev-api/src/main/java/io/openaev/migration/V6_20260825090000000__Add_is_marking_set_allowed_function.java b/openaev-api/src/main/java/io/openaev/migration/V6_20260825090000000__Add_is_marking_set_allowed_function.java new file mode 100644 index 00000000000..55fefc37e88 --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/migration/V6_20260825090000000__Add_is_marking_set_allowed_function.java @@ -0,0 +1,64 @@ +package io.openaev.migration; + +import java.sql.Statement; +import org.flywaydb.core.api.migration.BaseJavaMigration; +import org.flywaydb.core.api.migration.Context; +import org.springframework.stereotype.Component; + +/** + * Adds {@code is_marking_set_allowed(row_marking_ids)}: true when the caller holds every + * marking carried by the row, according to the clearance in the {@code app.current_markings} + * setting. + * + *

A marking is a many-to-many association, but the set is stored inline on the marked row as a + * {@code text[]}, so visibility is a containment test rather than a join: + * + *

{@code
+ * is_marking_set_allowed(t.marking_ids)   -- one local column, like can_access_tenant
+ * }
+ * + *

{@code <@} ("is contained by") is the AND semantics of STIX {@code + * object_marking_refs}: every marking on the row must be in the clearance. Two consequences follow + * for free, and both are the intended behaviour — an unmarked row is the empty set, and the empty + * set is contained in everything, so it stays visible to everyone; and adding a marking can only + * ever reduce visibility. + * + *

The two {@code COALESCE}s are load-bearing rather than defensive. {@code NULL <@ anything} and + * {@code anything <@ NULL} both yield NULL, which a WHERE clause drops — so without them a row with + * a NULL {@code marking_ids} would be hidden (wrong: it is unmarked) and, with no clearance set, + * every row including the unmarked ones would disappear. Normalising both sides to {@code '{}'} + * gives the right answer on both counts: no clearance hides every marked row and keeps the unmarked + * ones. + * + *

Do not rewrite this as {@code NOT (row_marking_ids && lacked_markings)}. Overlap + * against the set of markings the caller lacks is the GIN-friendly formulation and is therefore + * tempting, but that set is "every marking minus mine": a marking definition created after the + * clearance was resolved is in neither, so rows carrying it become visible. Containment + * against the held set fails closed on the same event. The fast form and the correct form are not + * the same form. + * + *

Ordinality (TLP:RED covers TLP:AMBER covers TLP:GREEN…) is resolved in Java when the clearance + * is built, so the setting holds the expanded set of marking ids and this function stays a + * plain containment test with no knowledge of marking types or orders. + */ +@Component +public class V6_20260825090000000__Add_is_marking_set_allowed_function extends BaseJavaMigration { + + @Override + public void migrate(Context context) throws Exception { + try (Statement statement = context.getConnection().createStatement()) { + statement.execute( + """ + CREATE OR REPLACE FUNCTION is_marking_set_allowed(row_marking_ids text[]) + RETURNS boolean + LANGUAGE sql STABLE PARALLEL SAFE AS $$ + SELECT COALESCE(row_marking_ids, '{}'::text[]) + <@ COALESCE( + string_to_array( + NULLIF(current_setting('app.current_markings', true), ''), ','), + '{}'::text[]) + $$; + """); + } + } +} diff --git a/openaev-api/src/main/java/io/openaev/migration/V6_20260825140000000__Add_marking_definitions.java b/openaev-api/src/main/java/io/openaev/migration/V6_20260825140000000__Add_marking_definitions.java new file mode 100644 index 00000000000..53d530bcd3a --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/migration/V6_20260825140000000__Add_marking_definitions.java @@ -0,0 +1,119 @@ +package io.openaev.migration; + +import java.sql.Statement; +import org.flywaydb.core.api.migration.BaseJavaMigration; +import org.flywaydb.core.api.migration.Context; +import org.springframework.stereotype.Component; + +/** + * Marking definitions — the vocabulary a clearance is expressed in (Task 2, step 2.1 of the marking + * design). + * + *

Two tables, deliberately different shapes: + * + *

    + *
  • {@code marking_definitions} — tenant-scoped catalogue of markings. Every tenant gets its + * own TLP and PAP scales, so the uniqueness constraint is composite on {@code (name, + * tenant_id)}. + *
  • {@code groups_markings} — the clearance grant: which markings the members of a group + * are allowed to see. It answers "what can this group see?", not "who may see this group", + * which is why it stays a join table and never becomes a marked table itself. + *
+ * + *

{@code marking_definitions} carries no {@code marking_ids} column: marking the marking + * catalogue would make clearance resolution depend on a clearance, which fails closed to "nobody + * sees anything". Tenant isolation is v2 (statement inspector), so there is no Hibernate + * {@code @Filter} on the entity. + */ +@Component +public class V6_20260825140000000__Add_marking_definitions extends BaseJavaMigration { + + /** Ordinals leave room to insert levels later without renumbering. */ + private static final String[][] DEFAULT_MARKINGS = { + // type, name, order, color + {"TLP", "TLP:CLEAR", "10", "#ffffff"}, + {"TLP", "TLP:GREEN", "20", "#2e7d32"}, + {"TLP", "TLP:AMBER", "30", "#d84315"}, + {"TLP", "TLP:AMBER+STRICT", "40", "#d84315"}, + {"TLP", "TLP:RED", "50", "#c62828"}, + {"PAP", "PAP:CLEAR", "10", "#ffffff"}, + {"PAP", "PAP:GREEN", "20", "#2e7d32"}, + {"PAP", "PAP:AMBER", "30", "#d84315"}, + {"PAP", "PAP:RED", "50", "#c62828"}, + }; + + @Override + public void migrate(Context context) throws Exception { + try (Statement statement = context.getConnection().createStatement()) { + statement.execute( + """ + CREATE TABLE IF NOT EXISTS marking_definitions ( + marking_id VARCHAR(255) NOT NULL CONSTRAINT marking_definitions_pkey PRIMARY KEY, + marking_type VARCHAR(255) NOT NULL, + marking_name VARCHAR(255) NOT NULL, + marking_order INT NOT NULL, + marking_color VARCHAR(255), + marking_created_at TIMESTAMP NOT NULL DEFAULT now(), + marking_updated_at TIMESTAMP NOT NULL DEFAULT now(), + tenant_id VARCHAR(255) NOT NULL + CONSTRAINT fk_marking_definitions_tenant_id + REFERENCES tenants (tenant_id) ON DELETE CASCADE + ); + """); + + statement.execute( + "CREATE INDEX IF NOT EXISTS idx_marking_definitions_tenant_id ON marking_definitions (tenant_id);"); + // Composite on tenant_id: two tenants may each define "TLP:RED" independently. + statement.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS idx_marking_definitions_name_tenant_unique + ON marking_definitions (marking_name, tenant_id); + """); + // Serves the resolver's "highest order held per type" read. + statement.execute( + """ + CREATE INDEX IF NOT EXISTS idx_marking_definitions_tenant_type_order + ON marking_definitions (tenant_id, marking_type, marking_order); + """); + + statement.execute( + """ + CREATE TABLE IF NOT EXISTS groups_markings ( + group_id VARCHAR(255) NOT NULL + CONSTRAINT fk_groups_markings_group_id + REFERENCES groups (group_id) ON DELETE CASCADE, + marking_id VARCHAR(255) NOT NULL + CONSTRAINT fk_groups_markings_marking_id + REFERENCES marking_definitions (marking_id) ON DELETE CASCADE, + CONSTRAINT groups_markings_pkey PRIMARY KEY (group_id, marking_id) + ); + """); + statement.execute( + "CREATE INDEX IF NOT EXISTS idx_groups_markings_marking_id ON groups_markings (marking_id);"); + + seedDefaults(statement); + } + } + + /** + * Seeds the TLP and PAP scales for every existing tenant. New tenants are seeded by {@code + * MarkingDefinitionDependenciesManager} at creation time; this covers the ones that already + * exist. + * + *

Idempotent through {@code ON CONFLICT DO NOTHING} against the composite unique index, so a + * tenant that somehow already defined "TLP:RED" keeps its own row. + */ + private void seedDefaults(Statement statement) throws Exception { + for (String[] marking : DEFAULT_MARKINGS) { + statement.execute( + """ + INSERT INTO marking_definitions + (marking_id, marking_type, marking_name, marking_order, marking_color, tenant_id) + SELECT gen_random_uuid()::text, '%s', '%s', %s, '%s', t.tenant_id + FROM tenants t + ON CONFLICT (marking_name, tenant_id) DO NOTHING; + """ + .formatted(marking[0], marking[1], marking[2], marking[3])); + } + } +} diff --git a/openaev-api/src/main/java/io/openaev/migration/V6_20260826120000000__Mark_assets.java b/openaev-api/src/main/java/io/openaev/migration/V6_20260826120000000__Mark_assets.java new file mode 100644 index 00000000000..d1119cce7ce --- /dev/null +++ b/openaev-api/src/main/java/io/openaev/migration/V6_20260826120000000__Mark_assets.java @@ -0,0 +1,55 @@ +package io.openaev.migration; + +import java.sql.Statement; +import org.flywaydb.core.api.migration.BaseJavaMigration; +import org.flywaydb.core.api.migration.Context; +import org.springframework.stereotype.Component; + +/** + * Marks {@code assets} — the first marking-enabled table (step 3.1 of the marking design). + * + *

The whole schema change is one nullable array column. There is no join table, no foreign key + * and no cascade, which is the point of the inline-array shape (design §3.2, Option 2): the marked + * table's primary key never appears in the predicate, so relationship tables and composite keys are + * marked with no special case. It also means nothing global has to be regenerated when the next + * table is onboarded. + * + *

No backfill, deliberately. {@code is_marking_set_allowed} coalesces a {@code NULL} + * array to {@code '{}'}, and the empty set is contained in every clearance, so every existing asset + * stays visible to everyone the moment the column appears. A marking can only ever reduce + * visibility, never grant it, so adding the column is inert until something writes a marking. + * Backfilling 200k+ rows with {@code '{}'} would rewrite the table for no behavioural difference. + * + *

Why the index is on an expression rather than on the column. The rewritten predicate is + * {@code COALESCE(marking_ids,'{}') <@ COALESCE(,'{}')} — the left side is an + * expression, so a plain {@code GIN (marking_ids)} index can never match it and would be + * pure write-amplification. Verified on a 200k-row probe: with {@code enable_seqscan=off} the + * planner still refused a plain GIN index and accepted this one. + * + *

Note this index is not expected to be chosen yet, and that is correct: while almost + * every row is unmarked the predicate matches ~100% of the table and a sequential scan is genuinely + * cheaper (14 ms for 200k rows on the same probe). It earns its keep once a meaningful fraction of + * rows is hidden from the reader. It is created now because doing it later means an index build on + * a large live table. + * + *

An index on the function itself is impossible: {@code is_marking_set_allowed} reads a GUC and + * is therefore {@code STABLE}, and only {@code IMMUTABLE} expressions can be indexed. + */ +@Component +public class V6_20260826120000000__Mark_assets extends BaseJavaMigration { + + @Override + public void migrate(Context context) throws Exception { + try (Statement statement = context.getConnection().createStatement()) { + statement.execute("ALTER TABLE assets ADD COLUMN IF NOT EXISTS marking_ids text[];"); + + // The COALESCE must match the one inlined from is_marking_set_allowed byte for byte, or the + // planner will not recognise the index as applicable to the predicate. + statement.execute( + """ + CREATE INDEX IF NOT EXISTS idx_assets_marking_ids + ON assets USING GIN ((COALESCE(marking_ids, '{}'::text[]))); + """); + } + } +} diff --git a/openaev-api/src/main/java/io/openaev/rest/asset/endpoint/form/EndpointOutput.java b/openaev-api/src/main/java/io/openaev/rest/asset/endpoint/form/EndpointOutput.java index 387f2eafc41..2a7ef59774a 100644 --- a/openaev-api/src/main/java/io/openaev/rest/asset/endpoint/form/EndpointOutput.java +++ b/openaev-api/src/main/java/io/openaev/rest/asset/endpoint/form/EndpointOutput.java @@ -60,6 +60,17 @@ public class EndpointOutput { @JsonProperty("asset_tags") private Set tags; + /** + * Marking definition ids carried by the asset, resolved to names and colours client-side like + * {@code asset_tags}. + * + *

Exposing these leaks nothing: a row only reaches the caller when its markings are a subset + * of their clearance, so every id here is one the caller already holds. + */ + @Schema(description = "Marking definition ids carried by the asset") + @JsonProperty("asset_markings") + private Set markings; + @Schema(description = "Asset category") @JsonProperty("asset_category") private AssetCategory category; diff --git a/openaev-api/src/main/java/io/openaev/service/TenantGroupService.java b/openaev-api/src/main/java/io/openaev/service/TenantGroupService.java index ff34f26b776..779b5e73667 100644 --- a/openaev-api/src/main/java/io/openaev/service/TenantGroupService.java +++ b/openaev-api/src/main/java/io/openaev/service/TenantGroupService.java @@ -1,19 +1,24 @@ package io.openaev.service; +import static io.openaev.api.markings.MarkingEscalationValidator.assertCanAssignMarkings; import static io.openaev.database.model.Role.capabilitiesOf; import static io.openaev.database.specification.GroupSpecification.tenantScope; import static io.openaev.service.account.PrivilegeEscalationValidator.assertCanAssignCapabilities; import static io.openaev.service.account.PrivilegeEscalationValidator.assertCanAssignGrant; +import io.openaev.api.groups.dto.GroupUpdateMarkingsInput; import io.openaev.api.groups.dto.TenantGroupCreateInput; +import io.openaev.config.cache.MarkingClearanceCacheManager; import io.openaev.context.TenantContext; import io.openaev.database.model.CapabilityScope; import io.openaev.database.model.Grant; import io.openaev.database.model.Group; +import io.openaev.database.model.MarkingDefinition; import io.openaev.database.model.Role; import io.openaev.database.model.Tenant; import io.openaev.database.model.User; import io.openaev.database.repository.GroupRepository; +import io.openaev.database.repository.MarkingDefinitionRepository; import io.openaev.database.repository.UserRepository; import io.openaev.rest.exception.ElementNotFoundException; import io.openaev.rest.group.form.GroupGrantInput; @@ -41,6 +46,8 @@ public class TenantGroupService { private final TenantRoleService tenantRoleService; private final UserService userService; private final GrantService grantService; + private final MarkingDefinitionRepository markingDefinitionRepository; + private final MarkingClearanceCacheManager markingClearanceCacheManager; @PersistenceContext private EntityManager entityManager; // -- CREATE -- @@ -152,8 +159,67 @@ public Group updateGroupUsers(@NotBlank final String groupId, GroupUpdateUsersIn if (users.size() != uniqueUserIds.size()) { throw new ElementNotFoundException("One or more users not found in the current tenant"); } + // Union of before and after: a user dropped from the group loses clearance (fail-open if the + // stale entry survives), a user added gains it (fail-closed, but still wrong until evicted). + Set affected = new LinkedHashSet<>(group.getUsers().stream().map(User::getId).toList()); + affected.addAll(uniqueUserIds); + group.setUsers(users); - return groupRepository.save(group); + Group saved = groupRepository.save(group); + markingClearanceCacheManager.evictForUsers(affected); + return saved; + } + + // -- MARKINGS -- + + /** + * Replaces the markings the group grants its members. + * + *

Two guards, in this order: + * + *

    + *
  1. Existence and tenant. {@code marking_definitions} is a tenant-active table, so the + * statement inspector already restricts this read to the request scope: a marking from + * another tenant simply does not come back, and the size check turns that into a 404 rather + * than a silent partial assignment. + *
  2. Escalation. {@link MarkingEscalationValidator} — you may not grant what you do not + * hold. Without it, "may manage groups" would quietly mean "may read every marked row". + *
+ * + *

🔴 The eviction at the end is not an optimisation. A cached clearance is pure set + * containment and never re-reads {@code groups_markings}, so a revoked grant that stays cached + * keeps granting access: fail-open. Union of before and after, because {@code setMarkings} + * replaces wholesale — a member losing a marking is only visible in the old set. + * + * @param tenantId resolved in the API layer and passed in, per the multi-tenancy convention + */ + public Group updateGroupMarkings( + @NotBlank final String tenantId, + @NotBlank final String groupId, + GroupUpdateMarkingsInput input) { + Group group = this.findByIdInTenantForWrite(groupId); + + Set uniqueMarkingIds = new LinkedHashSet<>(input.markingIds()); + // CrudRepository returns Iterable; the list is small (a tenant's scale) and needed twice. + List markings = new ArrayList<>(); + markingDefinitionRepository.findAllById(uniqueMarkingIds).forEach(markings::add); + if (markings.size() != uniqueMarkingIds.size()) { + throw new ElementNotFoundException( + "One or more marking definitions not found in the current tenant"); + } + + User currentUser = userService.currentUser(); + assertCanAssignMarkings( + markingClearanceCacheManager.findClearance( + currentUser.getId(), tenantId, currentUser.isAdminOrBypass()), + markings); + + Set affected = new LinkedHashSet<>(group.getUsers().stream().map(User::getId).toList()); + + group.setMarkings(markings); + Group saved = groupRepository.save(group); + markingClearanceCacheManager.evictForUsers(affected); + return saved; } // -- DELETE -- @@ -162,8 +228,11 @@ public void delete(@NotBlank final String groupId) { Group group = this.findByIdInTenantForWrite(groupId); // Clear bidirectional associations before delete to avoid TransientObjectException // (User entities in the persistence context would otherwise still reference the removed Group) + List members = group.getUsers().stream().map(User::getId).toList(); group.getUsers().forEach(user -> user.getUnscopedGroups().remove(group)); groupRepository.delete(group); + // Deleting the group revokes whatever markings it granted, for every member at once. + markingClearanceCacheManager.evictForUsers(members); } // -- GRANTS -- diff --git a/openaev-api/src/main/java/io/openaev/service/platform/groups/PlatformGroupService.java b/openaev-api/src/main/java/io/openaev/service/platform/groups/PlatformGroupService.java index f12c8d9dc03..ccd51413103 100644 --- a/openaev-api/src/main/java/io/openaev/service/platform/groups/PlatformGroupService.java +++ b/openaev-api/src/main/java/io/openaev/service/platform/groups/PlatformGroupService.java @@ -6,6 +6,7 @@ import static io.openaev.service.account.PrivilegeEscalationValidator.assertCanAssignCapabilities; import static io.openaev.utils.pagination.PaginationUtils.buildPaginationJPA; +import io.openaev.config.cache.MarkingClearanceCacheManager; import io.openaev.database.model.Group; import io.openaev.database.model.Role; import io.openaev.database.model.User; @@ -38,6 +39,7 @@ public class PlatformGroupService { private final UserRepository userRepository; private final UserService userService; private final ReferenceResolver referenceResolver; + private final MarkingClearanceCacheManager markingClearanceCacheManager; // -- CREATE -- @@ -139,10 +141,16 @@ public List updateGroupUsers(@NotBlank final String groupId, List uniqueUserIds = new LinkedHashSet<>(userIds); + // Union of before and after: a user dropped from the group loses clearance (fail-open if the + // stale entry survives), a user added gains it (fail-closed, but still wrong until evicted). + Set affected = new LinkedHashSet<>(group.getUsers().stream().map(User::getId).toList()); + affected.addAll(uniqueUserIds); + group.setUsers( new ArrayList<>( referenceResolver.resolve(uniqueUserIds, User.class, userRepository::countByIdIn))); groupRepository.save(group); + markingClearanceCacheManager.evictForUsers(affected); return groupRepository.findUserIdsByGroupId(groupId); } @@ -152,7 +160,10 @@ public void delete(@NotBlank final String groupId) { Group group = findById(groupId); // Clear bidirectional associations before delete to avoid TransientObjectException // (User entities in the persistence context would otherwise still reference the removed Group) + List members = group.getUsers().stream().map(User::getId).toList(); group.getUsers().forEach(user -> user.getUnscopedGroups().remove(group)); groupRepository.delete(group); + // Deleting the group revokes whatever markings it granted, for every member at once. + markingClearanceCacheManager.evictForUsers(members); } } diff --git a/openaev-api/src/main/java/io/openaev/utils/mapper/EndpointMapper.java b/openaev-api/src/main/java/io/openaev/utils/mapper/EndpointMapper.java index b555001c5cb..72db1df14b9 100644 --- a/openaev-api/src/main/java/io/openaev/utils/mapper/EndpointMapper.java +++ b/openaev-api/src/main/java/io/openaev/utils/mapper/EndpointMapper.java @@ -13,6 +13,7 @@ import io.openaev.rest.asset.endpoint.output.EndpointTargetOutput; import java.util.Arrays; import java.util.HashSet; +import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; import lombok.RequiredArgsConstructor; @@ -35,6 +36,15 @@ public class EndpointMapper { final AgentMapper agentMapper; + /** + * Marking ids are stored as a nullable {@code text[]} (unmarked rows are {@code null} or empty), + * so normalise to an empty set rather than propagating {@code null} into the DTO. + */ + private static Set toMarkingIds(Asset asset) { + String[] markingIds = asset.getMarkingIds(); + return markingIds == null ? emptySet() : Arrays.stream(markingIds).collect(Collectors.toSet()); + } + /** * Converts an endpoint to a standard output DTO. * @@ -53,6 +63,7 @@ public EndpointOutput toEndpointOutput(Endpoint endpoint) { .platform(endpoint.getPlatform()) .arch(endpoint.getArch()) .tags(endpoint.getTags().stream().map(Tag::getId).collect(Collectors.toSet())) + .markings(toMarkingIds(endpoint)) .category(endpoint.getCategory()) .subcategory(endpoint.getSubcategory()) .criticality(endpoint.getCriticality()) @@ -83,6 +94,7 @@ public EndpointOutput toAssetOutput(Asset asset) { .externalReference(asset.getExternalReference()) .agents(emptySet()) .tags(asset.getTags().stream().map(Tag::getId).collect(Collectors.toSet())) + .markings(toMarkingIds(asset)) .category(asset.getCategory()) .subcategory(asset.getSubcategory()) .criticality(asset.getCriticality()) diff --git a/openaev-api/src/main/resources/application.properties b/openaev-api/src/main/resources/application.properties index f93202ab100..bc938528a89 100644 --- a/openaev-api/src/main/resources/application.properties +++ b/openaev-api/src/main/resources/application.properties @@ -605,7 +605,14 @@ openaev.enabled-dev-features= # table, filtered through the finding it is joined to). # autonomous_runs / autonomous_events / autonomous_directives are v2-native (TenantBase, no # @Filter; TenantBaseListener removed on activation) so they MUST stay here (#7396). -openaev.tenant.active-tables=import_mappers,lessons_templates,mitigations,cwes,collectors,executors,injectors,attackpath_execution,attackpath_finding,secret_references,secrets,connector_instances,autonomous_runs,autonomous_events,autonomous_directives,security_coverages +# marking_definitions is v2-native (TenantBase, no @Filter, no TenantBaseListener; MarkingDefinitionApi +# attributes writes through TenantWriteScopeResolver) so it MUST stay here (#7510). +openaev.tenant.active-tables=import_mappers,lessons_templates,mitigations,cwes,collectors,executors,injectors,attackpath_execution,attackpath_finding,secret_references,secrets,connector_instances,autonomous_runs,autonomous_events,autonomous_directives,security_coverages,marking_definitions + +# Tables filtered by marking clearance (statement inspector + is_marking_set_allowed). A table is +# eligible once it has a `marking_ids text[]` column; listing it here turns the filtering on. +# Empty means the marking dimension is inert and the emitted SQL is unchanged. +openaev.marking.active-tables=assets ############################# # Attack path diff --git a/openaev-api/src/test/java/io/openaev/api/asset/AssetMarkingsApiTest.java b/openaev-api/src/test/java/io/openaev/api/asset/AssetMarkingsApiTest.java new file mode 100644 index 00000000000..ed2a17a7729 --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/api/asset/AssetMarkingsApiTest.java @@ -0,0 +1,255 @@ +package io.openaev.api.asset; + +import static io.openaev.utils.JsonTestUtils.asJsonString; +import static io.openaev.utils.fixtures.MarkingDefinitionFixture.createMarkingDefinition; +import static io.openaev.utils.fixtures.MarkingDefinitionFixture.uniqueName; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import io.openaev.IntegrationTest; +import io.openaev.api.asset.dto.AssetUpdateMarkingsInput; +import io.openaev.config.cache.MarkingClearanceCacheManager; +import io.openaev.context.TenantContext; +import io.openaev.database.model.Capability; +import io.openaev.database.model.Endpoint; +import io.openaev.database.model.Group; +import io.openaev.database.model.MarkingDefinition; +import io.openaev.database.model.User; +import io.openaev.database.repository.GroupRepository; +import io.openaev.utils.fixtures.EndpointFixture; +import io.openaev.utils.fixtures.TenantGroupFixture; +import io.openaev.utils.fixtures.composers.EndpointComposer; +import io.openaev.utils.fixtures.composers.MarkingDefinitionComposer; +import io.openaev.utils.fixtures.composers.TenantGroupComposer; +import io.openaev.utils.mockUser.WithMockUser; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import java.util.ArrayList; +import java.util.List; +import javax.sql.DataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +/** + * {@code PUT /api/assets/{id}/markings} — design step 3.3, the write path that puts a label on a + * row. + * + *

The caller is deliberately not an admin: an admin resolves to the whole tenant scale + * and would pass the escalation guard for the wrong reason, making every negative case below + * vacuous. + * + *

Assertions read {@code marking_ids} with raw JDBC rather than through the repository. After + * the PUT the entity is in the persistence context, so a repository read is served from Hibernate's + * first-level cache and would happily confirm whatever the service put there in memory. JDBC is the + * ground truth of what actually reached the column. + */ +@Transactional +@WithMockUser(withCapabilities = {Capability.MANAGE_ASSETS}) +@DisplayName("Asset markings API") +class AssetMarkingsApiTest extends IntegrationTest { + + private static final String ASSET_URI = "/api/assets"; + + @Autowired private MockMvc mvc; + @Autowired private DataSource dataSource; + @Autowired private GroupRepository groupRepository; + @Autowired private TenantGroupComposer tenantGroupComposer; + @Autowired private EndpointComposer endpointComposer; + @Autowired private MarkingDefinitionComposer markingDefinitionComposer; + @Autowired private MarkingClearanceCacheManager clearanceCache; + @PersistenceContext private EntityManager entityManager; + + private JdbcTemplate jdbc; + private String tenantId; + private String userId; + + private MarkingDefinition green; + private MarkingDefinition red; + private Endpoint asset; + + @BeforeEach + void setUp() { + jdbc = new JdbcTemplate(dataSource); + tenantGroupComposer.reset(); + endpointComposer.reset(); + markingDefinitionComposer.reset(); + + tenantId = TenantContext.getCurrentTenant(); + User user = testUserHolder.get(); + userId = user.getId(); + tenantRepository.addUserToTenant(userId, tenantId); + tenantMembershipCacheManager.evict(userId, tenantId); + + // Orders above the seeded 10..50 band so a fixture never ties with a default. + green = marking(60); + red = marking(70); + + asset = + endpointComposer + .forEndpoint(EndpointFixture.createEndpoint("asset-markings-" + uniqueName())) + .persist() + .get(); + + Group group = + tenantGroupComposer + .forGroup(TenantGroupFixture.getGroup("asset-markings-" + uniqueName())) + .persist() + .get(); + group.setUsers(new ArrayList<>(List.of(user))); + groupRepository.save(group); + + // The rows must exist in the database, not just in the persistence context, before the raw-JDBC + // grant below can reference them. + entityManager.flush(); + + // The caller holds GREEN and nothing above it — the whole point of the negative cases. + jdbc.update( + "INSERT INTO groups_markings (group_id, marking_id) VALUES (?, ?)", + group.getId(), + green.getId()); + clearanceCache.evictForUser(userId); + } + + @Nested + @DisplayName("assigning a marking") + class Assigning { + + @Test + @DisplayName("given a marking the caller holds, should write it to the asset") + void given_heldMarking_should_writeIt() throws Exception { + // -- ACT -- + assignMarkings(List.of(green.getId())).andExpect(status().is2xxSuccessful()); + + // -- ASSERT -- + assertArrayEquals(new String[] {green.getId()}, storedMarkings()); + } + + @Test + @DisplayName("given a marking the caller does not hold, should refuse with 403") + void given_unheldMarking_should_forbid() throws Exception { + // -- ACT / ASSERT -- + // The guard that makes marking a boundary at all: without it, anyone able to edit an asset + // could label it RED and then read every other RED row by joining a group they control. + assignMarkings(List.of(red.getId())).andExpect(status().isForbidden()); + + // The refusal must also be a no-op, not a partial write. + assertEquals(0, storedMarkings().length, "a refused assignment must not touch the column"); + } + + @Test + @DisplayName("given a marking it just assigned, should leave the asset readable by the caller") + void given_assignedMarking_should_notLockTheCallerOut() throws Exception { + // -- ARRANGE -- + assignMarkings(List.of(green.getId())).andExpect(status().is2xxSuccessful()); + entityManager.flush(); + entityManager.clear(); + + // -- ACT / ASSERT -- + // 🔴 Self-lockout is impossible BY CONSTRUCTION, and this pins that reasoning. The escalation + // guard enforces requested ⊆ clearance, and a row is visible iff row_markings ⊆ clearance — + // so the asset you just marked is still yours to read. There is no separate check for it, and + // this test is what would catch the guard being loosened to allow one. + mvc.perform(get(ASSET_URI + "/" + asset.getId())).andExpect(status().is2xxSuccessful()); + } + + @Test + @DisplayName("given an unknown marking id, should report not found") + void given_unknownMarking_should_notFound() throws Exception { + // -- ACT / ASSERT -- + assignMarkings(List.of("does-not-exist")).andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("removing markings") + class Removing { + + @Test + @DisplayName("given an empty list, should clear the markings and make the asset public again") + void given_emptyList_should_clearMarkings() throws Exception { + // -- ARRANGE -- + assignMarkings(List.of(green.getId())).andExpect(status().is2xxSuccessful()); + + // -- ACT -- + assignMarkings(List.of()).andExpect(status().is2xxSuccessful()); + + // -- ASSERT -- + // Declassification is allowed here precisely because the caller could already read the row; + // it is logged rather than blocked. An empty set is contained in every clearance, so the + // asset becomes visible to everyone again. + assertEquals(0, storedMarkings().length); + } + + @Test + @DisplayName("given an asset marked above the caller's clearance, should not find it") + void given_assetAboveClearance_should_notFound() throws Exception { + // -- ARRANGE -- + // Seeded out of band: an ORM write of RED would itself be blocked, which is the guard working + // but useless for arranging the fixture. + entityManager.flush(); + jdbc.update( + "UPDATE assets SET marking_ids = ? WHERE asset_id = ?", + (Object) new String[] {red.getId()}, + asset.getId()); + entityManager.clear(); + + // -- ACT / ASSERT -- + // 404 rather than 403: you cannot declassify what you cannot see, and the response must not + // confirm that a RED asset exists at this id. + assignMarkings(List.of()).andExpect(status().isNotFound()); + } + } + + // -- HELPERS -- + + private ResultActions assignMarkings(List markingIds) throws Exception { + return mvc.perform( + put(ASSET_URI + "/" + asset.getId() + "/markings") + .content(asJsonString(new AssetUpdateMarkingsInput(markingIds))) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .with(csrf())); + } + + /** Ground truth: what actually reached the column, bypassing Hibernate's first-level cache. */ + private String[] storedMarkings() { + entityManager.flush(); + java.sql.Array array = + jdbc.queryForObject( + "SELECT marking_ids FROM assets WHERE asset_id = ?", + java.sql.Array.class, + asset.getId()); + if (array == null) { + return new String[0]; + } + try { + String[] stored = (String[]) array.getArray(); + assertNotNull(stored); + return stored; + } catch (java.sql.SQLException e) { + throw new IllegalStateException("could not read marking_ids", e); + } + } + + private MarkingDefinition marking(int order) { + return markingDefinitionComposer + .forMarkingDefinition( + createMarkingDefinition(MarkingDefinition.TYPE_TLP, uniqueName(), order, "#c62828")) + .withTenantId(tenantId) + .persist() + .get(); + } +} diff --git a/openaev-api/src/test/java/io/openaev/api/groups/TenantGroupMarkingsApiTest.java b/openaev-api/src/test/java/io/openaev/api/groups/TenantGroupMarkingsApiTest.java new file mode 100644 index 00000000000..5b4c5d2d899 --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/api/groups/TenantGroupMarkingsApiTest.java @@ -0,0 +1,263 @@ +package io.openaev.api.groups; + +import static io.openaev.api.groups.TenantGroupApi.TENANT_GROUP_URI; +import static io.openaev.utils.JsonTestUtils.asJsonString; +import static io.openaev.utils.fixtures.MarkingDefinitionFixture.createMarkingDefinition; +import static io.openaev.utils.fixtures.MarkingDefinitionFixture.uniqueName; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import io.openaev.IntegrationTest; +import io.openaev.api.groups.dto.GroupUpdateMarkingsInput; +import io.openaev.config.cache.MarkingClearanceCacheManager; +import io.openaev.context.MarkingCtx; +import io.openaev.context.TenantContext; +import io.openaev.database.model.Group; +import io.openaev.database.model.MarkingDefinition; +import io.openaev.database.model.User; +import io.openaev.database.repository.GroupRepository; +import io.openaev.utils.TenantIsolationTestHelper; +import io.openaev.utils.fixtures.TenantGroupFixture; +import io.openaev.utils.fixtures.composers.MarkingDefinitionComposer; +import io.openaev.utils.fixtures.composers.TenantGroupComposer; +import io.openaev.utils.mockUser.WithMockUser; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +/** + * {@code PUT /api/groups/{id}/markings} — the write path that gives a user a clearance. + * + *

This is the endpoint the marking PoC was blocked on: until it existed, {@code groups_markings} + * had no writer, so every user resolved to {@link MarkingCtx#none()} and the clearance path could + * only be exercised against a stubbed {@code JdbcTemplate}. Here it runs against real rows. + * + *

Assertions are containment-based, never equality: every tenant is seeded with nine + * default markings (TLP:CLEAR..RED, PAP:CLEAR..RED), and ordinality expansion legitimately pulls + * the seeded low orders into any clearance. Asserting an exact set would be asserting the seed. + * + *

Also pins the {@code TenantGroupService} entry in {@code TenantActiveTableAccessArchTest}: the + * cross-tenant case below is what makes "the inspector scopes this read" checkable rather than a + * claim in a comment. + */ +@TestInstance(PER_CLASS) +@Transactional +@WithMockUser(isAdmin = true) +@DisplayName("Tenant group markings API") +class TenantGroupMarkingsApiTest extends IntegrationTest { + + @Autowired private MockMvc mvc; + @Autowired private GroupRepository groupRepository; + @Autowired private TenantGroupComposer tenantGroupComposer; + @Autowired private MarkingDefinitionComposer markingDefinitionComposer; + @Autowired private MarkingClearanceCacheManager clearanceCache; + @Autowired private TenantIsolationTestHelper tenantHelper; + @PersistenceContext private EntityManager entityManager; + + private String tenantId; + private String userId; + private Group group; + private MarkingDefinition green; + private MarkingDefinition red; + + @BeforeEach + void setUp() { + tenantGroupComposer.reset(); + markingDefinitionComposer.reset(); + tenantId = TenantContext.getCurrentTenant(); + User user = testUserHolder.get(); + userId = user.getId(); + tenantRepository.addUserToTenant(userId, tenantId); + tenantMembershipCacheManager.evict(userId, tenantId); + + // Orders above the seeded 10..50 band so a fixture never ties with a default, and RED above + // GREEN so the ordinality expansion below is unambiguous. + green = persistedMarking(60); + red = persistedMarking(70); + + group = + tenantGroupComposer + .forGroup(TenantGroupFixture.getGroup("markings-" + uniqueName())) + .persist() + .get(); + // The caller must be a member: a clearance is what a group grants ITS MEMBERS. + group.setUsers(new ArrayList<>(List.of(user))); + groupRepository.save(group); + clearanceCache.evictForUser(userId); + } + + @Nested + @DisplayName("granting a clearance") + class Granting { + + @Test + @DisplayName("given a group the user belongs to, should grant exactly that marking") + void given_groupTheUserBelongsTo_should_grantThatMarking() throws Exception { + // -- ACT -- + assignMarkings(List.of(green.getId())).andExpect(status().is2xxSuccessful()); + + // -- ASSERT -- + // Flow 1: a member of a group marked GREEN holds GREEN — and not RED. + Set clearance = clearanceOfMember(); + assertTrue(clearance.contains(green.getId()), "GREEN should be held: " + clearance); + assertFalse(clearance.contains(red.getId()), "RED must not be held: " + clearance); + } + + @Test + @DisplayName("given a higher marking, should grant the lower ones it implies") + void given_higherMarking_should_grantTheImpliedLowerOnes() throws Exception { + // -- ACT -- + assignMarkings(List.of(red.getId())).andExpect(status().is2xxSuccessful()); + + // -- ASSERT -- + // Ordinality is resolved in Java, not SQL: granting RED alone must already yield a FLAT set + // containing GREEN, because the database predicate is plain set containment and knows nothing + // about order. + Set clearance = clearanceOfMember(); + assertTrue(clearance.contains(red.getId()), "RED should be held: " + clearance); + assertTrue(clearance.contains(green.getId()), "RED must imply GREEN: " + clearance); + } + + @Test + @DisplayName("given a re-assignment, should reflect it immediately — the cache is evicted") + void given_reassignment_should_reflectItImmediately() throws Exception { + // -- ARRANGE -- + assignMarkings(List.of(green.getId())).andExpect(status().is2xxSuccessful()); + // Warm the cache deliberately: without eviction the next read would serve this value. + assertFalse(clearanceOfMember().contains(red.getId())); + + // -- ACT -- + assignMarkings(List.of(red.getId())).andExpect(status().is2xxSuccessful()); + + // -- ASSERT -- + // Flow 3: an admin raises the group to RED and the member sees RED on their next request, + // rather than after the 5-minute TTL. + assertTrue(clearanceOfMember().contains(red.getId()), "eviction did not happen"); + } + + @Test + @DisplayName("given an empty list, should revoke every marking") + void given_emptyList_should_revokeEverything() throws Exception { + // -- ARRANGE -- + assignMarkings(List.of(red.getId())).andExpect(status().is2xxSuccessful()); + assertTrue(clearanceOfMember().contains(red.getId())); + + // -- ACT -- + assignMarkings(List.of()).andExpect(status().is2xxSuccessful()); + + // -- ASSERT -- + // 🔴 The direction that fails OPEN if eviction is missed: a revoked grant that stays cached + // keeps granting access, because the cached set is never re-checked against the table. + Set clearance = clearanceOfMember(); + assertFalse(clearance.contains(red.getId()), "revoked RED still held: " + clearance); + assertFalse(clearance.contains(green.getId()), "revoked GREEN still held: " + clearance); + assertTrue(reloadedGroup().getMarkings().isEmpty()); + } + } + + @Nested + @DisplayName("guards") + class Guards { + + @Test + @DisplayName("given an unknown marking, should 404 rather than assign the rest") + void given_unknownMarking_should_notFound() throws Exception { + // -- ACT -- + assignMarkings(List.of(green.getId(), "does-not-exist")).andExpect(status().isNotFound()); + + // -- ASSERT -- + // A partial assignment is the dangerous outcome: the caller believes they granted two + // markings and only one was audited. + assertTrue(reloadedGroup().getMarkings().isEmpty()); + } + + @Test + @DisplayName("given a marking from another tenant, should refuse to assign it") + void given_markingFromAnotherTenant_should_refuse() throws Exception { + // -- ARRANGE -- + String otherTenantId = tenantHelper.createTenant("marking-assign-other").getId(); + MarkingDefinition foreign = + markingDefinitionComposer + .forMarkingDefinition( + createMarkingDefinition(MarkingDefinition.TYPE_TLP, uniqueName(), 60, "#111111")) + .withTenantId(otherTenantId) + .persist() + .get(); + + // -- ACT -- + assignMarkings(List.of(foreign.getId())).andExpect(status().isForbidden()); + + // -- ASSERT -- + // 🔴 403, not 404, and the difference is the point. Two independent guards cover this: + // + // 1. the statement inspector, because marking_definitions is tenant-active — it would hide + // the row and the size check would 404; + // 2. the escalation guard, because a clearance is per tenant, so nobody — admin included — + // holds another tenant's marking. + // + // Guard 1 does NOT fire here: the composer persisted this entity into the same persistence + // context, so findAllById is served from Hibernate's first-level cache and never reaches SQL. + // An inspector cannot rewrite a query that is not issued. That is a real property of the + // mechanism worth stating in a test rather than discovering in production, and it is exactly + // why the escalation guard is not redundant with tenant isolation. + assertTrue(reloadedGroup().getMarkings().isEmpty()); + } + } + + // -- HELPERS -- + + private ResultActions assignMarkings(List markingIds) throws Exception { + return mvc.perform( + put(tenantUri(TENANT_GROUP_URI) + "/" + group.getId() + "/markings") + .content(asJsonString(new GroupUpdateMarkingsInput(markingIds))) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .with(csrf())); + } + + /** + * The member's own clearance, resolved WITHOUT bypass: the caller performing the assignment is an + * admin and would otherwise hold the whole tenant scale, which would make every assertion above + * pass for the wrong reason. + */ + private Set clearanceOfMember() { + // The clearance read is raw JDBC (it must not open a Hibernate session — see + // MarkingClearanceCacheManager). It joins this test's transaction, but Hibernate has not + // written the groups_markings rows yet: nothing commits inside a rolled-back test. Without this + // flush the query would correctly return zero rows and the test would be measuring the flush. + entityManager.flush(); + MarkingCtx ctx = clearanceCache.findClearance(userId, tenantId, false); + return ctx instanceof MarkingCtx.Restricted restricted + ? Set.copyOf(restricted.markingIds()) + : Set.of(); + } + + private Group reloadedGroup() { + return groupRepository.findById(group.getId()).orElseThrow(); + } + + private MarkingDefinition persistedMarking(int order) { + return markingDefinitionComposer + .forMarkingDefinition( + createMarkingDefinition(MarkingDefinition.TYPE_TLP, uniqueName(), order, "#c62828")) + .withTenantId(tenantId) + .persist() + .get(); + } +} diff --git a/openaev-api/src/test/java/io/openaev/api/markings/AssetMarkingIsolationTest.java b/openaev-api/src/test/java/io/openaev/api/markings/AssetMarkingIsolationTest.java new file mode 100644 index 00000000000..1a61482c1d1 --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/api/markings/AssetMarkingIsolationTest.java @@ -0,0 +1,302 @@ +package io.openaev.api.markings; + +import static io.openaev.utils.JsonTestUtils.asJsonString; +import static io.openaev.utils.fixtures.MarkingDefinitionFixture.createMarkingDefinition; +import static io.openaev.utils.fixtures.MarkingDefinitionFixture.uniqueName; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.jayway.jsonpath.JsonPath; +import io.openaev.IntegrationTest; +import io.openaev.config.cache.MarkingClearanceCacheManager; +import io.openaev.context.TenantContext; +import io.openaev.database.model.Capability; +import io.openaev.database.model.Endpoint; +import io.openaev.database.model.Group; +import io.openaev.database.model.MarkingDefinition; +import io.openaev.database.model.User; +import io.openaev.database.repository.GroupRepository; +import io.openaev.utils.fixtures.EndpointFixture; +import io.openaev.utils.fixtures.PaginationFixture; +import io.openaev.utils.fixtures.TenantGroupFixture; +import io.openaev.utils.fixtures.composers.EndpointComposer; +import io.openaev.utils.fixtures.composers.MarkingDefinitionComposer; +import io.openaev.utils.fixtures.composers.TenantGroupComposer; +import io.openaev.utils.mockUser.WithMockUser; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import java.util.ArrayList; +import java.util.List; +import javax.sql.DataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +/** + * Step 3.2 — the PoC's central claim, on a real table through real endpoints: a user cleared {@code + * TLP:GREEN} cannot see a {@code TLP:RED} endpoint, with no change to {@code EndpointRepository} + * or {@code EndpointService} read code. + * + *

Read that last clause as the assertion it is. Nothing in this test calls a marking-aware API. + * It calls {@code POST /api/endpoints/search} and {@code GET /api/endpoints/{id}} exactly as any + * other test does; the filtering happens because {@code assets} is on {@code + * openaev.marking.active-tables} and the statement inspector rewrites the SQL underneath. + * + *

{@code @TestPropertySource} activates the table for this class only, so the rest of the suite + * keeps running unmarked and this test stays honest about what activation costs. + * + *

Seeding is raw JDBC on purpose. The marking dimension uses the same predicate for reads + * and writes, so an ORM update that puts {@code TLP:RED} on a row would be blocked for a caller who + * does not hold {@code TLP:RED} — the guard doing its job, but useless for arranging a fixture. + * {@code JdbcTemplate} does not pass through Hibernate's statement inspector, which is precisely + * why it is the right tool for out-of-band seeding and the wrong one for product code. + */ +@Transactional +@TestPropertySource(properties = "openaev.marking.active-tables=assets") +@WithMockUser(withCapabilities = {Capability.ACCESS_ASSETS}) +@DisplayName("assets marking isolation through the real HTTP endpoints") +class AssetMarkingIsolationTest extends IntegrationTest { + + private static final String ENDPOINT_URI = "/api/endpoints"; + private static final String ENDPOINT_SEARCH_URI = ENDPOINT_URI + "/search"; + + @Autowired private MockMvc mvc; + @Autowired private DataSource dataSource; + @Autowired private GroupRepository groupRepository; + @Autowired private TenantGroupComposer tenantGroupComposer; + @Autowired private EndpointComposer endpointComposer; + @Autowired private MarkingDefinitionComposer markingDefinitionComposer; + @Autowired private MarkingClearanceCacheManager clearanceCache; + @PersistenceContext private EntityManager entityManager; + + private JdbcTemplate jdbc; + private String tenantId; + private String userId; + private Group group; + + private MarkingDefinition tlpGreen; + private MarkingDefinition tlpRed; + private MarkingDefinition papRed; + + private Endpoint unmarked; + private Endpoint greenOnly; + private Endpoint redOnly; + private Endpoint greenAndPapRed; + + @BeforeEach + void seed() { + jdbc = new JdbcTemplate(dataSource); + tenantGroupComposer.reset(); + endpointComposer.reset(); + markingDefinitionComposer.reset(); + + tenantId = TenantContext.getCurrentTenant(); + User user = testUserHolder.get(); + userId = user.getId(); + tenantRepository.addUserToTenant(userId, tenantId); + tenantMembershipCacheManager.evict(userId, tenantId); + + // Orders above the seeded 10..50 band so a fixture never ties with a default. + tlpGreen = marking(MarkingDefinition.TYPE_TLP, 60); + tlpRed = marking(MarkingDefinition.TYPE_TLP, 70); + papRed = marking(MarkingDefinition.TYPE_PAP, 70); + + unmarked = endpoint("unmarked"); + greenOnly = endpoint("green"); + redOnly = endpoint("red"); + greenAndPapRed = endpoint("green-and-pap-red"); + + group = + tenantGroupComposer + .forGroup(TenantGroupFixture.getGroup("marking-iso-" + uniqueName())) + .persist() + .get(); + group.setUsers(new ArrayList<>(List.of(user))); + groupRepository.save(group); + + // Flush before the raw-JDBC seeding below: the rows must exist in the database, not just in the + // persistence context, for an UPDATE to find them. + entityManager.flush(); + + mark(greenOnly, tlpGreen); + mark(redOnly, tlpRed); + mark(greenAndPapRed, tlpGreen, papRed); + } + + @Nested + @DisplayName("with a TLP:GREEN clearance") + class GreenCleared { + + @BeforeEach + void grantGreen() { + grant(tlpGreen); + } + + @Test + @DisplayName("given a search, should return unmarked and GREEN but never RED") + void given_search_should_hideRed() throws Exception { + // -- ACT -- + List visible = searchEndpointIds(); + + // -- ASSERT -- + // The unmarked row is the one people get wrong: fail-closed for marking means "see less", not + // "see nothing". An empty marking set is contained in every clearance, so it stays visible. + assertTrue(visible.contains(unmarked.getId()), "unmarked endpoint must stay visible"); + assertTrue(visible.contains(greenOnly.getId()), "GREEN endpoint must be visible"); + assertFalse(visible.contains(redOnly.getId()), "RED endpoint must be hidden"); + } + + @Test + @DisplayName("given a direct GET on a RED endpoint, should 404 — not 403") + void given_directGetOnRed_should_notFound() throws Exception { + // -- ACT / ASSERT -- + // 404 rather than 403 is the whole point of filtering by rewrite: the row is not "refused", + // it does not exist as far as this transaction's SQL is concerned. A 403 would confirm the + // endpoint exists, which is itself a disclosure. + mvc.perform(get(ENDPOINT_URI + "/" + redOnly.getId())).andExpect(status().isNotFound()); + } + + @Test + @DisplayName("given a direct GET on a GREEN endpoint, should succeed") + void given_directGetOnGreen_should_succeed() throws Exception { + // -- ACT / ASSERT -- + // The negative case above means nothing without this one: it rules out "everything 404s". + mvc.perform(get(ENDPOINT_URI + "/" + greenOnly.getId())) + .andExpect(status().is2xxSuccessful()); + } + + @Test + @DisplayName("given a row marked TLP:GREEN + PAP:RED, should hide it — AND, not OR") + void given_multiMarkedRow_should_requireEveryMarking() throws Exception { + // -- ACT -- + List visible = searchEndpointIds(); + + // -- ASSERT -- + // 🔴 The single most consequential semantic in the design. Holding ONE of a row's markings is + // not enough — a row is visible only when the reader holds them ALL. If this ever flips to + // OR, every multi-marked row leaks to anyone holding its weakest label. + assertFalse( + visible.contains(greenAndPapRed.getId()), + "TLP:GREEN + PAP:RED must be hidden from a TLP:GREEN-only clearance"); + assertTrue( + visible.contains(greenOnly.getId()), "the TLP:GREEN-only row must still be visible"); + } + } + + @Nested + @DisplayName("clearance boundaries") + class Boundaries { + + @Test + @DisplayName("given no clearance at all, should still see unmarked rows and nothing marked") + void given_noClearance_should_seeOnlyUnmarked() throws Exception { + // -- ACT -- + List visible = searchEndpointIds(); + + // -- ASSERT -- + // The default state of every user before anyone assigns anything. Activation must not be a + // platform-wide blackout, or nobody would ever be able to turn it on. + assertTrue(visible.contains(unmarked.getId()), "unmarked endpoint must stay visible"); + assertFalse(visible.contains(greenOnly.getId())); + assertFalse(visible.contains(redOnly.getId())); + } + + @Test + @DisplayName("given a TLP:RED clearance, should also see GREEN — higher implies lower") + void given_redClearance_should_alsoSeeGreen() throws Exception { + // -- ARRANGE -- + grant(tlpRed); + + // -- ACT -- + List visible = searchEndpointIds(); + + // -- ASSERT -- + // Ordinality is expanded in Java before the GUC is written, so the SQL predicate stays a flat + // containment test. This asserts that expansion actually reaches the database. + assertTrue(visible.contains(redOnly.getId()), "RED endpoint must be visible"); + assertTrue(visible.contains(greenOnly.getId()), "RED clearance must imply GREEN"); + assertTrue(visible.contains(unmarked.getId())); + } + + @Test + @DisplayName("given both scales granted, should see the row that needs both") + void given_bothScales_should_seeTheMultiMarkedRow() throws Exception { + // -- ARRANGE -- + // Types are independent scales: TLP says nothing about PAP, so both must be granted. + grant(tlpGreen, papRed); + + // -- ACT -- + List visible = searchEndpointIds(); + + // -- ASSERT -- + assertTrue( + visible.contains(greenAndPapRed.getId()), + "holding both TLP:GREEN and PAP:RED must reveal the row needing both"); + assertFalse(visible.contains(redOnly.getId()), "TLP:RED is still not held"); + } + } + + // -- HELPERS -- + + private List searchEndpointIds() throws Exception { + String response = + mvc.perform( + post(ENDPOINT_SEARCH_URI) + .content(asJsonString(PaginationFixture.getDefault().size(200).build())) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .with(csrf())) + .andExpect(status().is2xxSuccessful()) + .andReturn() + .getResponse() + .getContentAsString(); + return JsonPath.read(response, "$.content[*].asset_id"); + } + + /** Grants markings to the group the user belongs to, and drops the cached clearance. */ + private void grant(MarkingDefinition... markings) { + for (MarkingDefinition marking : markings) { + jdbc.update( + "INSERT INTO groups_markings (group_id, marking_id) VALUES (?, ?)" + + " ON CONFLICT DO NOTHING", + group.getId(), + marking.getId()); + } + clearanceCache.evictForUser(userId); + } + + /** Out-of-band write: see the class javadoc for why this is not an ORM save. */ + private void mark(Endpoint endpoint, MarkingDefinition... markings) { + jdbc.update( + "UPDATE assets SET marking_ids = ? WHERE asset_id = ?", + (Object) + java.util.Arrays.stream(markings).map(MarkingDefinition::getId).toArray(String[]::new), + endpoint.getId()); + } + + private MarkingDefinition marking(String type, int order) { + return markingDefinitionComposer + .forMarkingDefinition(createMarkingDefinition(type, uniqueName(), order, "#c62828")) + .withTenantId(tenantId) + .persist() + .get(); + } + + private Endpoint endpoint(String name) { + return endpointComposer + .forEndpoint(EndpointFixture.createEndpoint("marking-iso-" + name + "-" + uniqueName())) + .persist() + .get(); + } +} diff --git a/openaev-api/src/test/java/io/openaev/api/markings/MarkingDefinitionApiTest.java b/openaev-api/src/test/java/io/openaev/api/markings/MarkingDefinitionApiTest.java new file mode 100644 index 00000000000..44e353f34b9 --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/api/markings/MarkingDefinitionApiTest.java @@ -0,0 +1,398 @@ +package io.openaev.api.markings; + +import static io.openaev.utils.JsonTestUtils.asJsonString; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.jayway.jsonpath.JsonPath; +import io.openaev.IntegrationTest; +import io.openaev.api.markings.form.MarkingDefinitionInput; +import io.openaev.context.TenantContext; +import io.openaev.database.model.MarkingDefinition; +import io.openaev.database.repository.MarkingDefinitionRepository; +import io.openaev.utils.fixtures.MarkingDefinitionFixture; +import io.openaev.utils.fixtures.PaginationFixture; +import io.openaev.utils.fixtures.composers.MarkingDefinitionComposer; +import io.openaev.utils.mockUser.WithMockUser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.transaction.annotation.Transactional; + +/** + * CRUD coverage for {@link MarkingDefinitionApi}. + * + *

Tenant isolation is NOT tested here — it needs {@code marking_definitions} in {@code + * openaev.tenant.active-tables}, which is a class-level {@code @TestPropertySource} and would fork + * the Spring context for this whole class. It lives in {@code MarkingDefinitionHttpIsolationTest}. + * + *

Every request carries an explicit {@code X-Tenant-Ids} selector. {@code create} resolves its + * write tenant through {@code TenantWriteScopeResolver}, which refuses a scope that does not pin + * exactly one tenant; pinning it here makes the test independent of how many tenants the mock user + * happens to belong to. + */ +@TestInstance(PER_CLASS) +@Transactional +@WithMockUser(isAdmin = true) +@DisplayName("Marking definition API tests") +class MarkingDefinitionApiTest extends IntegrationTest { + + // MarkingDefinitionApi declares its path inline in @RequestMapping rather than as a shared + // constant, so it cannot be static-imported here. + static final String MARKING_DEFINITION_URI = "/api/marking-definitions"; + static final String TENANT_IDS_HEADER = "X-Tenant-Ids"; + + @Autowired private MockMvc mvc; + @Autowired private MarkingDefinitionComposer markingDefinitionComposer; + @Autowired private MarkingDefinitionRepository markingDefinitionRepository; + + private String tenantId; + + @BeforeEach + void setUp() { + markingDefinitionComposer.reset(); + tenantId = TenantContext.getCurrentTenant(); + // Keep membership in sync with the TxCtx resolver: an unauthorised selector is a 403, not a + // silent drop. + String userId = testUserHolder.get().getId(); + tenantRepository.addUserToTenant(userId, tenantId); + tenantMembershipCacheManager.evict(userId, tenantId); + } + + private MarkingDefinitionComposer.Composer persisted(MarkingDefinition marking) { + return markingDefinitionComposer.forMarkingDefinition(marking).withTenantId(tenantId).persist(); + } + + private MockHttpServletRequestBuilder scoped(MockHttpServletRequestBuilder builder) { + return builder.header(TENANT_IDS_HEADER, tenantId); + } + + private MockHttpServletRequestBuilder jsonBody( + MockHttpServletRequestBuilder builder, Object body) { + return scoped(builder) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content(asJsonString(body)) + .with(csrf()); + } + + // -- CREATE -- + + @Nested + @DisplayName("POST /api/marking-definitions") + class CreateMarkingDefinition { + + @Test + @DisplayName("Valid input creates the marking and persists every field") + void given_validInput_should_createMarkingDefinition() throws Exception { + // -- ARRANGE -- + MarkingDefinitionInput input = + MarkingDefinitionFixture.createInput( + MarkingDefinition.TYPE_PAP, + MarkingDefinitionFixture.uniqueName(), + MarkingDefinitionFixture.DEFAULT_ORDER, + MarkingDefinitionFixture.DEFAULT_COLOR); + + // -- ACT -- + String response = + mvc.perform(jsonBody(post(MARKING_DEFINITION_URI), input)) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + + // -- ASSERT -- + String createdId = JsonPath.read(response, "$.marking_id"); + assertEquals(input.type(), JsonPath.read(response, "$.marking_type")); + assertEquals(input.name(), JsonPath.read(response, "$.marking_name")); + assertEquals(input.order(), JsonPath.read(response, "$.marking_order")); + assertEquals(input.color(), JsonPath.read(response, "$.marking_color")); + + MarkingDefinition persisted = markingDefinitionRepository.findById(createdId).orElseThrow(); + assertEquals(input.type(), persisted.getType()); + assertEquals(input.name(), persisted.getName()); + assertEquals(input.order(), persisted.getOrder()); + assertEquals(input.color(), persisted.getColor()); + assertEquals(tenantId, persisted.getTenant().getId()); + } + + @Test + @DisplayName("A name already taken is rejected") + void given_duplicateName_should_returnBadRequest() throws Exception { + // -- ARRANGE -- + String takenName = MarkingDefinitionFixture.uniqueName(); + persisted(MarkingDefinitionFixture.createMarkingDefinitionWithName(takenName)); + + // -- ACT & ASSERT -- + mvc.perform( + jsonBody( + post(MARKING_DEFINITION_URI), + MarkingDefinitionFixture.createInputWithName(takenName))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("A colour that is not a hex code fails bean validation") + void given_invalidColor_should_returnBadRequest() throws Exception { + // -- ARRANGE & ACT & ASSERT -- + mvc.perform( + jsonBody( + post(MARKING_DEFINITION_URI), + MarkingDefinitionFixture.createInputWithColor( + MarkingDefinitionFixture.INVALID_COLOR))) + .andExpect(status().isBadRequest()); + } + } + + // -- READ -- + + @Nested + @DisplayName("GET /api/marking-definitions/{markingId}") + class GetMarkingDefinitionById { + + @Test + @DisplayName("An existing id returns the marking") + void given_existingId_should_returnMarkingDefinition() throws Exception { + // -- ARRANGE -- + MarkingDefinition marking = + persisted(MarkingDefinitionFixture.createDefaultMarkingDefinition()).get(); + + // -- ACT -- + String response = + mvc.perform(scoped(get(MARKING_DEFINITION_URI + "/" + marking.getId()))) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + // -- ASSERT -- + assertEquals(marking.getId(), JsonPath.read(response, "$.marking_id")); + assertEquals(marking.getName(), JsonPath.read(response, "$.marking_name")); + assertEquals(marking.getType(), JsonPath.read(response, "$.marking_type")); + assertEquals(marking.getOrder(), JsonPath.read(response, "$.marking_order")); + } + + @Test + @DisplayName("An unknown id is not found") + void given_unknownId_should_returnNotFound() throws Exception { + // -- ARRANGE & ACT & ASSERT -- + mvc.perform(scoped(get(MARKING_DEFINITION_URI + "/does-not-exist"))) + .andExpect(status().isNotFound()); + } + } + + // -- SEARCH -- + + @Nested + @DisplayName("POST /api/marking-definitions/search") + class SearchMarkingDefinitions { + + @Test + @DisplayName("A text search returns only the matching marking") + void given_textSearch_should_returnMatchingMarking() throws Exception { + // -- ARRANGE -- + // The migration seeds nine defaults per tenant, so assert on a needle only this fixture + // carries rather than on a total count. + MarkingDefinition needle = + persisted(MarkingDefinitionFixture.createDefaultMarkingDefinition()).get(); + MarkingDefinition other = + persisted(MarkingDefinitionFixture.createDefaultMarkingDefinition()).get(); + + // -- ACT -- + String response = + mvc.perform( + jsonBody( + post(MARKING_DEFINITION_URI + "/search"), + PaginationFixture.simpleTextSearch(needle.getName()))) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + // -- ASSERT -- + assertEquals(1, (int) JsonPath.read(response, "$.totalElements")); + assertEquals(needle.getId(), JsonPath.read(response, "$.content[0].marking_id")); + assertFalse( + response.contains(other.getId()), "a non-matching marking must not appear in results"); + } + + @Test + @DisplayName("A page smaller than the result set paginates") + void given_pageSizeSmallerThanResults_should_paginate() throws Exception { + // -- ARRANGE -- + // A shared token scopes the search to this fixture group, so the nine seeded defaults (and + // anything another test left behind) cannot perturb the counts. + String token = MarkingDefinitionFixture.uniqueSearchToken(); + for (int i = 0; i < 3; i++) { + persisted(MarkingDefinitionFixture.createMarkingDefinitionWithName(token + ":LEVEL" + i)); + } + + // -- ACT -- + String response = + mvc.perform( + jsonBody( + post(MARKING_DEFINITION_URI + "/search"), + PaginationFixture.getDefault().textSearch(token).size(2).build())) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + // -- ASSERT -- + assertEquals(3, (int) JsonPath.read(response, "$.totalElements")); + assertEquals(2, (int) JsonPath.read(response, "$.totalPages")); + assertEquals(2, ((java.util.List) JsonPath.read(response, "$.content")).size()); + } + + @Test + @DisplayName("A search matching nothing returns an empty page") + void given_noMatch_should_returnEmptyPage() throws Exception { + // -- ARRANGE & ACT -- + String response = + mvc.perform( + jsonBody( + post(MARKING_DEFINITION_URI + "/search"), + PaginationFixture.simpleTextSearch( + MarkingDefinitionFixture.uniqueSearchToken()))) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + // -- ASSERT -- + assertEquals(0, (int) JsonPath.read(response, "$.totalElements")); + } + } + + // -- UPDATE -- + + @Nested + @DisplayName("PUT /api/marking-definitions/{markingId}") + class UpdateMarkingDefinition { + + @Test + @DisplayName("An existing marking is updated and the new values persist") + void given_existingMarking_should_updateSuccessfully() throws Exception { + // -- ARRANGE -- + MarkingDefinition marking = + persisted(MarkingDefinitionFixture.createDefaultMarkingDefinition()).get(); + MarkingDefinitionInput update = + MarkingDefinitionFixture.createInput( + MarkingDefinition.TYPE_PAP, + MarkingDefinitionFixture.uniqueName(), + 99, + MarkingDefinitionFixture.ALTERNATE_COLOR); + + // -- ACT -- + String response = + mvc.perform(jsonBody(put(MARKING_DEFINITION_URI + "/" + marking.getId()), update)) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + // -- ASSERT -- + assertEquals(marking.getId(), JsonPath.read(response, "$.marking_id")); + assertEquals(update.name(), JsonPath.read(response, "$.marking_name")); + assertEquals(update.type(), JsonPath.read(response, "$.marking_type")); + assertEquals(update.order(), JsonPath.read(response, "$.marking_order")); + assertEquals(update.color(), JsonPath.read(response, "$.marking_color")); + + MarkingDefinition reloaded = + markingDefinitionRepository.findById(marking.getId()).orElseThrow(); + assertEquals(update.name(), reloaded.getName()); + assertEquals(update.order(), reloaded.getOrder()); + } + + @Test + @DisplayName("Renaming onto another marking's name is rejected") + void given_collidingName_should_returnBadRequest() throws Exception { + // -- ARRANGE -- + MarkingDefinition first = + persisted(MarkingDefinitionFixture.createDefaultMarkingDefinition()).get(); + MarkingDefinition second = + persisted(MarkingDefinitionFixture.createDefaultMarkingDefinition()).get(); + + // -- ACT & ASSERT -- + mvc.perform( + jsonBody( + put(MARKING_DEFINITION_URI + "/" + second.getId()), + MarkingDefinitionFixture.createInputWithName(first.getName()))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("Keeping its own name is not treated as a collision") + void given_sameName_should_updateSuccessfully() throws Exception { + // -- ARRANGE -- + MarkingDefinition marking = + persisted(MarkingDefinitionFixture.createDefaultMarkingDefinition()).get(); + MarkingDefinitionInput update = + MarkingDefinitionFixture.createInput( + marking.getType(), marking.getName(), 77, MarkingDefinitionFixture.ALTERNATE_COLOR); + + // -- ACT & ASSERT -- + mvc.perform(jsonBody(put(MARKING_DEFINITION_URI + "/" + marking.getId()), update)) + .andExpect(status().isOk()); + assertEquals( + 77, markingDefinitionRepository.findById(marking.getId()).orElseThrow().getOrder()); + } + + @Test + @DisplayName("Updating an unknown id is not found") + void given_unknownId_should_returnNotFound() throws Exception { + // -- ARRANGE & ACT & ASSERT -- + mvc.perform( + jsonBody( + put(MARKING_DEFINITION_URI + "/does-not-exist"), + MarkingDefinitionFixture.createDefaultInput())) + .andExpect(status().isNotFound()); + } + } + + // -- DELETE -- + + @Nested + @DisplayName("DELETE /api/marking-definitions/{markingId}") + class DeleteMarkingDefinition { + + @Test + @DisplayName("An existing marking is deleted and then no longer readable") + void given_existingMarking_should_deleteSuccessfully() throws Exception { + // -- ARRANGE -- + MarkingDefinition marking = + persisted(MarkingDefinitionFixture.createDefaultMarkingDefinition()).get(); + + // -- ACT -- + mvc.perform(scoped(delete(MARKING_DEFINITION_URI + "/" + marking.getId())).with(csrf())) + .andExpect(status().isNoContent()); + + // -- ASSERT -- + mvc.perform(scoped(get(MARKING_DEFINITION_URI + "/" + marking.getId()))) + .andExpect(status().isNotFound()); + assertTrue(markingDefinitionRepository.findById(marking.getId()).isEmpty()); + } + + @Test + @DisplayName("Deleting an unknown id is not found") + void given_unknownId_should_returnNotFound() throws Exception { + // -- ARRANGE & ACT & ASSERT -- + mvc.perform(scoped(delete(MARKING_DEFINITION_URI + "/does-not-exist")).with(csrf())) + .andExpect(status().isNotFound()); + } + } +} diff --git a/openaev-api/src/test/java/io/openaev/api/markings/MarkingDefinitionHttpIsolationTest.java b/openaev-api/src/test/java/io/openaev/api/markings/MarkingDefinitionHttpIsolationTest.java new file mode 100644 index 00000000000..da51ef862ad --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/api/markings/MarkingDefinitionHttpIsolationTest.java @@ -0,0 +1,353 @@ +package io.openaev.api.markings; + +import static io.openaev.utils.JsonTestUtils.asJsonString; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.jayway.jsonpath.JsonPath; +import io.openaev.IntegrationTest; +import io.openaev.database.model.MarkingDefinition; +import io.openaev.database.repository.MarkingDefinitionRepository; +import io.openaev.utils.TenantIsolationTestHelper; +import io.openaev.utils.fixtures.MarkingDefinitionFixture; +import io.openaev.utils.fixtures.PaginationFixture; +import io.openaev.utils.mockUser.WithMockUser; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.UUID; +import org.hibernate.Session; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +/** + * End-to-end proof that, with {@code marking_definitions} activated, the tenant scope isolates the + * table through the real {@link MarkingDefinitionApi} endpoints. + */ +@Transactional +@TestPropertySource(properties = "openaev.tenant.active-tables=marking_definitions") +@WithMockUser(isAdmin = true) +// @CoversTenantIsolation("marking_definitions") // uncomment when the tenant-scope-coverage CI gate +// lands +@DisplayName("marking_definitions read and write isolation through the real HTTP endpoint") +class MarkingDefinitionHttpIsolationTest extends IntegrationTest { + + private static final String MARKING_URI = "/api/marking-definitions"; + private static final String MARKING_BY_ID = MARKING_URI + "/{markingId}"; + private static final String TENANT_MARKING_URI = "/api/tenants/{tenantId}/marking-definitions"; + private static final String TENANT_MARKING_BY_ID = TENANT_MARKING_URI + "/{markingId}"; + private static final String TENANT_IDS_HEADER = "X-Tenant-Ids"; + + private static final String NAME_A = "ISO:MARKING-A"; + private static final String NAME_B = "ISO:MARKING-B"; + + @Autowired private MockMvc mvc; + @Autowired private TenantIsolationTestHelper tenantHelper; + @Autowired private MarkingDefinitionRepository markingDefinitionRepository; + + private String tenantA; + private String tenantB; + private String markingA; + private String markingB; + + @BeforeEach + void seedTwoTenantsWithOneMarkingEach() throws Exception { + tenantA = tenantHelper.createTenantWithCurrentUser("marking-iso-a").getId(); + tenantB = tenantHelper.createTenantWithCurrentUser("marking-iso-b").getId(); + // Native inserts, not API creates: two MockMvc creates would set the tenant scope twice in one + // transaction and TenantScopeTransactionAspect throws. Native inserts set no scope at all. + // Note each tenant ALSO gets the nine defaults from MarkingDefinitionDependenciesManager at + // creation; every assertion below keys off these two ids, never off a row count. + markingA = seedMarking(tenantA, NAME_A); + markingB = seedMarking(tenantB, NAME_B); + } + + // -- READ -- + + @Test + @DisplayName("under tenant A's scope: A's marking is visible, B's is hidden") + void given_tenantAScope_should_readOwnMarkingAndHideTenantBs() throws Exception { + // -- ACT & ASSERT -- + mvc.perform(get(MARKING_BY_ID, markingA).header(TENANT_IDS_HEADER, tenantA)) + .andExpect(status().isOk()); + mvc.perform(get(MARKING_BY_ID, markingB).header(TENANT_IDS_HEADER, tenantA)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("under tenant B's scope: B's marking is visible, A's is hidden") + void given_tenantBScope_should_readOwnMarkingAndHideTenantAs() throws Exception { + // -- ACT & ASSERT -- + mvc.perform(get(MARKING_BY_ID, markingB).header(TENANT_IDS_HEADER, tenantB)) + .andExpect(status().isOk()); + mvc.perform(get(MARKING_BY_ID, markingA).header(TENANT_IDS_HEADER, tenantB)) + .andExpect(status().isNotFound()); + } + + // -- SEARCH -- + + @Test + @DisplayName("via the X-Tenant-Ids header: search returns A's marking and not B's") + void given_tenantAScope_should_scopeSearchToTenantA() throws Exception { + // -- ARRANGE -- + String body = asJsonString(PaginationFixture.getDefault().textSearch("").size(200).build()); + + // -- ACT -- + String response = + mvc.perform( + post(MARKING_URI + "/search") + .header(TENANT_IDS_HEADER, tenantA) + .contentType(MediaType.APPLICATION_JSON) + .content(body) + .with(csrf())) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + // -- ASSERT -- + assertTrue( + response.contains(markingA), "A's marking must appear when A is selected via header"); + assertFalse(response.contains(markingB), "B's marking must not appear"); + } + + // -- PATH ROUTE (the selector the frontend actually uses) -- + + @Test + @DisplayName("via the /api/tenants/{tenantId} path: A's marking is visible, B's is hidden") + void given_tenantAPathRoute_should_readOwnMarkingAndHideTenantBs() throws Exception { + // -- ACT & ASSERT -- + mvc.perform(get(TENANT_MARKING_BY_ID, tenantA, markingA)).andExpect(status().isOk()); + mvc.perform(get(TENANT_MARKING_BY_ID, tenantA, markingB)).andExpect(status().isNotFound()); + } + + @Test + @DisplayName("via the /api/tenants/{tenantId} path: search is scoped to that tenant") + void given_tenantAPathRoute_should_scopeSearchToTenantA() throws Exception { + // -- ARRANGE -- + String body = asJsonString(PaginationFixture.getDefault().textSearch("").size(200).build()); + + // -- ACT -- + String response = + mvc.perform( + post(TENANT_MARKING_URI + "/search", tenantA) + .contentType(MediaType.APPLICATION_JSON) + .content(body) + .with(csrf())) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + // -- ASSERT -- + assertTrue(response.contains(markingA), "A's marking must appear on A's path route"); + assertFalse(response.contains(markingB), "B's marking must not appear"); + } + + // -- FAIL-CLOSED -- + + @Test + @DisplayName("no scope set: the repository read is empty although the row exists") + void given_noScopeInTransaction_should_returnNoRows() { + // No TxCtx resolved in this transaction (no MockMvc call), so app.current_tenants was never + // set and can_access_tenant denies every row. Fail-closed, not fail-open: this is what + // protects the table if an endpoint ever loses its TxCtx parameter. + // -- ACT & ASSERT -- + assertEquals(0L, markingDefinitionRepository.count(), "a scope-less read must see no rows"); + assertEquals(1L, rawCount(markingA), "the row exists, it is only hidden"); + } + + // -- CREATE (write attribution) -- + + @Test + @DisplayName("a create under tenant A's scope is attributed to tenant A") + void given_tenantAScope_should_attributeCreateToTenantA() throws Exception { + // -- ARRANGE -- + var input = MarkingDefinitionFixture.createInputWithName("ISO:CREATED-UNDER-A"); + + // -- ACT -- + String response = + mvc.perform( + post(MARKING_URI) + .header(TENANT_IDS_HEADER, tenantA) + .contentType(MediaType.APPLICATION_JSON) + .content(asJsonString(input)) + .with(csrf())) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + + // -- ASSERT -- + String createdId = JsonPath.read(response, "$.marking_id"); + // Raw JDBC, not an entityManager native query: the inspector rewrites the latter, so its + // result would flip with the transaction's scope. + assertEquals(tenantA, rawTenant(createdId), "the created marking must belong to tenant A"); + } + + @Test + @DisplayName("a create with no tenant selector is refused (a single-tenant scope is required)") + void given_noTenantSelector_should_rejectCreate() throws Exception { + // The mock user belongs to both seeded tenants, so an absent selector resolves to a + // multi-tenant scope; TenantWriteScopeResolver cannot attribute the row and refuses with 400. + // -- ACT & ASSERT -- + mvc.perform( + post(MARKING_URI) + .contentType(MediaType.APPLICATION_JSON) + .content( + asJsonString(MarkingDefinitionFixture.createInputWithName("ISO:NO-SELECTOR"))) + .with(csrf())) + .andExpect(status().isBadRequest()); + } + + // -- UPDATE -- + + @Test + @DisplayName("under tenant A's scope: A can update its own marking") + void given_tenantAScope_should_updateOwnMarking() throws Exception { + // -- ARRANGE -- + var update = MarkingDefinitionFixture.createInputWithName("ISO:RENAMED-A"); + + // -- ACT -- + mvc.perform( + put(MARKING_BY_ID, markingA) + .header(TENANT_IDS_HEADER, tenantA) + .contentType(MediaType.APPLICATION_JSON) + .content(asJsonString(update)) + .with(csrf())) + .andExpect(status().isOk()); + + // -- ASSERT -- + assertEquals("ISO:RENAMED-A", rawName(markingA), "A's own marking must be updated"); + } + + @Test + @DisplayName("under tenant A's scope: updating B's marking is not found and leaves it untouched") + void given_tenantAScope_should_notUpdateTenantBsMarking() throws Exception { + // -- ARRANGE -- + var update = MarkingDefinitionFixture.createInputWithName("ISO:HIJACKED"); + + // -- ACT -- + mvc.perform( + put(MARKING_BY_ID, markingB) + .header(TENANT_IDS_HEADER, tenantA) + .contentType(MediaType.APPLICATION_JSON) + .content(asJsonString(update)) + .with(csrf())) + .andExpect(status().isNotFound()); + + // -- ASSERT -- + assertEquals(NAME_B, rawName(markingB), "B's marking must be untouched by tenant A"); + } + + // -- DELETE -- + + @Test + @DisplayName("under tenant A's scope: A can delete its own marking") + void given_tenantAScope_should_deleteOwnMarking() throws Exception { + // -- ACT -- + mvc.perform(delete(MARKING_BY_ID, markingA).header(TENANT_IDS_HEADER, tenantA).with(csrf())) + .andExpect(status().isNoContent()); + + // -- ASSERT -- + assertEquals(0L, rawCount(markingA), "A's own marking must be deleted"); + } + + @Test + @DisplayName( + "under tenant A's scope: deleting B's marking does not happen and leaves it in place") + void given_tenantAScope_should_notDeleteTenantBsMarking() throws Exception { + // DEVIATION from the skill's "cross-tenant DELETE is a 2xx no-op": that semantic assumes the + // DELETE statement reaches the DB and the inspector narrows its WHERE. MarkingDefinitionService + // .delete() calls getOrThrow(id) FIRST, so the scoped lookup misses and the request 404s + // exactly like the cross-tenant PUT. The security property is identical and is what the ground + // truth below actually pins: B's row survives. + // -- ACT -- + mvc.perform(delete(MARKING_BY_ID, markingB).header(TENANT_IDS_HEADER, tenantA).with(csrf())) + .andExpect(status().isNotFound()); + + // -- ASSERT -- + assertEquals(1L, rawCount(markingB), "B's marking must survive tenant A's delete attempt"); + } + + // -- SEED -- + + // Native insert with an explicit tenant_id: the setup seeds TWO tenants, and two API creates + // would set the tenant scope twice in one transaction, which TenantScopeTransactionAspect + // rejects. marking_id is VARCHAR, so no CAST(? AS uuid) is needed anywhere in this file. + private String seedMarking(String tenantId, String name) { + String id = UUID.randomUUID().toString(); + entityManager + .createNativeQuery( + "INSERT INTO marking_definitions" + + " (marking_id, marking_type, marking_name, marking_order, marking_color, tenant_id)" + + " VALUES (:id, :type, :name, :order, :color, :tenant)") + .setParameter("id", id) + .setParameter("type", MarkingDefinition.TYPE_TLP) + .setParameter("name", name) + .setParameter("order", MarkingDefinitionFixture.DEFAULT_ORDER) + .setParameter("color", MarkingDefinitionFixture.DEFAULT_COLOR) + .setParameter("tenant", tenantId) + .executeUpdate(); + return id; + } + + // -- GROUND TRUTH -- + + // Raw JDBC on the test's own connection: it sees the uncommitted seed and the rewriter does not + // touch a statement it never generated. A flush first forces any pending scoped UPDATE/DELETE to + // reach the database. + private String rawName(String markingId) { + return rawString( + "SELECT marking_name FROM marking_definitions WHERE marking_id = ?", markingId); + } + + private String rawTenant(String markingId) { + return rawString("SELECT tenant_id FROM marking_definitions WHERE marking_id = ?", markingId); + } + + private String rawString(String sql, String markingId) { + entityManager.flush(); + return entityManager + .unwrap(Session.class) + .doReturningWork( + connection -> { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, markingId); + try (ResultSet rows = statement.executeQuery()) { + return rows.next() ? rows.getString(1) : null; + } + } + }); + } + + private long rawCount(String markingId) { + entityManager.flush(); + return entityManager + .unwrap(Session.class) + .doReturningWork( + connection -> { + try (PreparedStatement statement = + connection.prepareStatement( + "SELECT count(*) FROM marking_definitions WHERE marking_id = ?")) { + statement.setString(1, markingId); + try (ResultSet rows = statement.executeQuery()) { + rows.next(); + return rows.getLong(1); + } + } + }); + } +} diff --git a/openaev-api/src/test/java/io/openaev/api/markings/MarkingEscalationValidatorTest.java b/openaev-api/src/test/java/io/openaev/api/markings/MarkingEscalationValidatorTest.java new file mode 100644 index 00000000000..a583e984822 --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/api/markings/MarkingEscalationValidatorTest.java @@ -0,0 +1,132 @@ +package io.openaev.api.markings; + +import static io.openaev.api.markings.MarkingEscalationValidator.assertCanAssignMarkings; +import static io.openaev.utils.fixtures.MarkingDefinitionFixture.createMarkingDefinition; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.openaev.context.MarkingCtx; +import io.openaev.database.model.MarkingDefinition; +import io.openaev.rest.exception.ForbiddenException; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * The clearance-escalation guard (design Q7). + * + *

Unit rather than integration on purpose: the rule is a pure set comparison, and the + * interesting cases are the ones where the caller holds something but not enough — + * which through the API would need a non-admin user with hand-built grants for each case. + */ +@DisplayName("MarkingEscalationValidator: you may not grant what you do not hold") +class MarkingEscalationValidatorTest { + + private static MarkingDefinition marking(String id, String name) { + MarkingDefinition definition = + createMarkingDefinition(MarkingDefinition.TYPE_TLP, name, 10, "#000000"); + definition.setId(id); + return definition; + } + + @Nested + @DisplayName("refuses") + class Refuses { + + @Test + @DisplayName("given no clearance, should refuse assigning any marking") + void given_noClearance_should_refuse() { + // -- ARRANGE -- + List requested = List.of(marking("m-red", "TLP:RED")); + + // -- ACT -- + ForbiddenException thrown = + assertThrows( + ForbiddenException.class, + () -> assertCanAssignMarkings(MarkingCtx.none(), requested)); + + // -- ASSERT -- + // This is the default state of every user, and it is the case that makes the guard worth + // having: managing a group must not be a route to granting oneself TLP:RED. + assertTrue(thrown.getMessage().contains("TLP:RED"), thrown.getMessage()); + } + + @Test + @DisplayName("given a partial clearance, should refuse and name only the unheld markings") + void given_partialClearance_should_nameOnlyTheUnheldOnes() { + // -- ARRANGE -- + MarkingCtx clearance = MarkingCtx.forMarkings(List.of("m-green")); + + // -- ACT -- + ForbiddenException thrown = + assertThrows( + ForbiddenException.class, + () -> + assertCanAssignMarkings( + clearance, + List.of(marking("m-green", "TLP:GREEN"), marking("m-red", "TLP:RED")))); + + // -- ASSERT -- + // Naming the held one too would send the caller to fix something that is not wrong. + assertTrue(thrown.getMessage().contains("TLP:RED"), thrown.getMessage()); + assertTrue(!thrown.getMessage().contains("TLP:GREEN"), thrown.getMessage()); + } + + @Test + @DisplayName("given an unresolved all() intention, should refuse rather than treat it as total") + void given_allIntention_should_refuse() { + // -- ARRANGE -- + // all() is a background intention that must never reach an HTTP write. Treating it as "holds + // everything" would make a plumbing mistake into an escalation, so it is read as holding + // nothing. + MarkingCtx unresolved = MarkingCtx.all(); + + // -- ACT / ASSERT -- + assertThrows( + ForbiddenException.class, + () -> assertCanAssignMarkings(unresolved, List.of(marking("m-red", "TLP:RED")))); + } + } + + @Nested + @DisplayName("allows") + class Allows { + + @Test + @DisplayName("given the caller holds every requested marking, should allow") + void given_callerHoldsThem_should_allow() { + // -- ARRANGE -- + MarkingCtx clearance = MarkingCtx.forMarkings(List.of("m-green", "m-red")); + + // -- ACT / ASSERT -- + assertDoesNotThrow( + () -> + assertCanAssignMarkings( + clearance, + List.of(marking("m-green", "TLP:GREEN"), marking("m-red", "TLP:RED")))); + } + + @Test + @DisplayName("given a lower marking implied by a higher one, should allow") + void given_impliedMarking_should_allow() { + // -- ARRANGE -- + // MarkingCtx is already ordinality-expanded by MarkingScopeResolver, so holding TLP:RED means + // holding TLP:GREEN. Granting it discloses nothing the caller could not already read. + MarkingCtx expandedFromRed = MarkingCtx.forMarkings(List.of("m-green", "m-red")); + + // -- ACT / ASSERT -- + assertDoesNotThrow( + () -> assertCanAssignMarkings(expandedFromRed, List.of(marking("m-green", "TLP:GREEN")))); + } + + @Test + @DisplayName("given an empty request, should allow — revoking everything is always permitted") + void given_emptyRequest_should_allow() { + // -- ACT / ASSERT -- + // Revocation only ever narrows what the group's members can see. + assertDoesNotThrow(() -> assertCanAssignMarkings(MarkingCtx.none(), List.of())); + } + } +} diff --git a/openaev-api/src/test/java/io/openaev/architecture/TenantActiveTableAccessArchTest.java b/openaev-api/src/test/java/io/openaev/architecture/TenantActiveTableAccessArchTest.java index c455b624b6c..4e71efea5d1 100644 --- a/openaev-api/src/test/java/io/openaev/architecture/TenantActiveTableAccessArchTest.java +++ b/openaev-api/src/test/java/io/openaev/architecture/TenantActiveTableAccessArchTest.java @@ -8,7 +8,11 @@ import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchRule; +import io.openaev.api.asset.AssetMarkingsService; import io.openaev.api.chaining.InjectExecutionStep; +import io.openaev.api.markings.MarkingDefinitionDependenciesManager; +import io.openaev.api.markings.MarkingDefinitionService; +import io.openaev.context.TenantScopedTransaction; import io.openaev.database.model.CatalogConnector; import io.openaev.database.model.Exercise; import io.openaev.database.model.Inject; @@ -24,6 +28,7 @@ import io.openaev.database.repository.ImportMapperRepository; import io.openaev.database.repository.InjectorRepository; import io.openaev.database.repository.LessonsTemplateRepository; +import io.openaev.database.repository.MarkingDefinitionRepository; import io.openaev.database.repository.MitigationRepository; import io.openaev.database.repository.SecurityCoverageRepository; import io.openaev.database.repository.attackpath.AttackPathExecutionRepository; @@ -87,6 +92,7 @@ import io.openaev.service.MapperService; import io.openaev.service.ScenarioToExerciseService; import io.openaev.service.SecurityCoverageSendJobService; +import io.openaev.service.TenantGroupService; import io.openaev.service.attackpath.AttackPathCausalSeedService; import io.openaev.service.attackpath.AttackPathDeltaService; import io.openaev.service.attackpath.AttackPathGraphService; @@ -157,7 +163,8 @@ class TenantActiveTableAccessArchTest { "autonomous_runs", "autonomous_events", "autonomous_directives", - "security_coverages"); + "security_coverages", + "marking_definitions"); @ArchTest static void every_active_table_is_guarded(JavaClasses classes) throws Exception { @@ -568,6 +575,58 @@ static void every_active_table_is_guarded(JavaClasses classes) throws Exception "attackpath_finding is tenant-active: an accessor without a tenant scope silently" + " reads zero rows. New accessors must carry a scope and be allowlisted here"); + @ArchTest + static final ArchRule marking_definitions_repository_access_is_reviewed = + noClasses() + .that() + .doNotBelongToAnyOf( + // Own service; every public entrypoint is TxCtx-scoped, pinned by + // TenantScopedEntrypointsTxCtxArchTest: + MarkingDefinitionService.class, + // Seeds the default TLP/PAP scales at tenant creation. It writes rows for the tenant + // being created and never reads, so it needs no read scope; the tenant is set + // explicitly on every entity rather than inferred from a scope: + MarkingDefinitionDependenciesManager.class, + // Background primitive: reads the tenant's markings to assign the system clearance a + // background transaction runs at (all markings of the tenants in scope — a scheduler + // is not a user). Scoped, and in the strongest sense the rule asks for: the read + // happens INSIDE the transaction and AFTER setScope() has written + // app.current_tenants, so the inspector rewrites it with can_access_tenant, and the + // query additionally binds the same tenant ids explicitly. Cannot deadlock against + // its own dimension either: marking_definitions is deliberately NOT on + // openaev.marking.active-tables (design Q13 — the clearance source must not itself + // require a clearance), so no marking predicate applies to this read while the + // marking scope is still being computed. Pinned by + // TenantScopedTransactionMarkingScopeTest: + TenantScopedTransaction.class, + // Resolves the markings a group is being told to grant. Runs on the HTTP path inside + // a @Transactional method that takes a TxCtx, so app.current_tenants is already set + // and the inspector rewrites the read with can_access_tenant: a marking id belonging + // to another tenant does not come back, and the size mismatch becomes a 404 rather + // than a silent partial assignment. + // + // 🔴 The inspector is NOT relied on alone here, and the reason is worth knowing: it + // can only rewrite a query that is actually issued, so an entity already in the + // persistence context is served from Hibernate's first-level cache and never + // filtered (TenantGroupMarkingsApiTest demonstrates exactly this). The independent + // guarantee is MarkingEscalationValidator — a clearance is per tenant, so nobody + // holds another tenant's marking and the assignment is refused regardless. Pinned by + // TenantGroupMarkingsApiTest: + TenantGroupService.class, + // Resolves the markings an asset is being labelled with. Same shape as + // TenantGroupService above: HTTP path, inside a @Transactional method taking a TxCtx, + // so app.current_tenants is set and the read is rewritten with can_access_tenant — + // another tenant's marking id does not come back and the size mismatch becomes a 404. + // The escalation guard is again the independent check, for the L1-cache reason + // spelled out above. Pinned by AssetMarkingsApiTest: + AssetMarkingsService.class) + .should() + .dependOnClassesThat() + .areAssignableTo(MarkingDefinitionRepository.class) + .because( + "marking_definitions is tenant-active: an accessor without a tenant scope silently" + + " reads zero rows. New accessors must carry a scope and be allowlisted here"); + @ArchTest static final ArchRule security_coverages_repository_access_is_reviewed = noClasses() diff --git a/openaev-api/src/test/java/io/openaev/architecture/TenantScopedEntrypointsTxCtxArchTest.java b/openaev-api/src/test/java/io/openaev/architecture/TenantScopedEntrypointsTxCtxArchTest.java index 18de39a5901..7973fb59d7f 100644 --- a/openaev-api/src/test/java/io/openaev/architecture/TenantScopedEntrypointsTxCtxArchTest.java +++ b/openaev-api/src/test/java/io/openaev/architecture/TenantScopedEntrypointsTxCtxArchTest.java @@ -363,7 +363,14 @@ class TenantScopedEntrypointsTxCtxArchTest { "io.openaev.api.autonomous.AutonomousRunApi#promoteFindingToAsset", "io.openaev.api.autonomous.AutonomousRunApi#ensureTargetTeam", "io.openaev.rest.scenario.ScenarioApi#deleteScenario", - "io.openaev.rest.scenario.ScenarioApi#bulkDeleteScenarios"); + "io.openaev.rest.scenario.ScenarioApi#bulkDeleteScenarios", + // marking_definitions (v2, v2-native): the whole CRUD surface. Losing the TxCtx here + // fails silently — reads return zero markings rather than erroring (#7510). + "io.openaev.api.markings.MarkingDefinitionApi#create", + "io.openaev.api.markings.MarkingDefinitionApi#getById", + "io.openaev.api.markings.MarkingDefinitionApi#search", + "io.openaev.api.markings.MarkingDefinitionApi#update", + "io.openaev.api.markings.MarkingDefinitionApi#delete"); @ArchTest static final ArchRule tx_scoped_entrypoints_must_declare_tx_ctx = diff --git a/openaev-api/src/test/java/io/openaev/config/MarkingCoexistsWithTenantV1Test.java b/openaev-api/src/test/java/io/openaev/config/MarkingCoexistsWithTenantV1Test.java new file mode 100644 index 00000000000..7b208a148e6 --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/config/MarkingCoexistsWithTenantV1Test.java @@ -0,0 +1,127 @@ +package io.openaev.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Pins decision Q10: a table may stay on tenant isolation v1 (a Hibernate {@code @Filter}, + * i.e. absent from {@code openaev.tenant.active-tables}) and be marking-active at the same time. + * This is what lets marking be activated on {@code assets} and {@code asset_groups} without waiting + * for their tenant v2 migration. + * + *

The two mechanisms act at different layers — v1 injects its condition while Hibernate + * generates the SQL, marking rewrites the finished string — so the only way they could + * collide is through bind parameters. The marking predicate carries its clearance in a session + * setting and adds no {@code ?}; if it ever did, the wrap being inserted in the FROM (which + * precedes the WHERE) would shift every later placeholder and silently break positional binding. + * Every case below therefore asserts the placeholder count is preserved. + */ +@DisplayName("Marking coexists with tenant isolation v1") +class MarkingCoexistsWithTenantV1Test { + + /** Tenant v2 inactive (empty tables) — the table is on v1 — while marking is active. */ + private final ScopeStatementInspector inspector = + new ScopeStatementInspector( + List.of( + new TenantDimension(new TenantTables(Set.of(), Set.of())), + new MarkingDimension( + new MarkedTables(Map.of("documents", new MarkedTable("documents")))))); + + private static long placeholders(String sql) { + return sql.chars().filter(c -> c == '?').count(); + } + + /** Rewrites, asserts the placeholder count is untouched, and returns the flattened SQL. */ + private String rewrite(String sql) { + String out = inspector.inspect(sql); + assertEquals( + placeholders(sql), + placeholders(out), + "the rewrite must not change the placeholder count, or positional binding breaks: " + out); + return out.replaceAll("\\s+", " ").trim(); + } + + private static final String MARKING_PREDICATE = "is_marking_set_allowed(d.marking_ids)"; + + @Test + @DisplayName("no tenant predicate is emitted for a table that is still on v1") + void noTenantPredicateOnAV1Table() { + String out = rewrite("SELECT d.doc_id FROM documents d WHERE d.tenant_id = ?"); + assertTrue(!out.contains("can_access_tenant"), out); + } + + @Test + @DisplayName("the v1 filter condition survives verbatim next to the marking predicate") + void v1ConditionSurvives() { + String out = + rewrite("SELECT d.doc_id, d.name FROM documents d WHERE d.tenant_id = ? AND d.name = ?"); + // Marking filters the wrapped source, the v1 condition stays in the outer WHERE: both apply. + assertTrue( + out.contains("(SELECT * FROM documents d WHERE " + MARKING_PREDICATE + ") AS d"), out); + assertTrue(out.endsWith("WHERE d.tenant_id = ? AND d.name = ?"), out); + } + + @Test + @DisplayName("the wrapper projects everything, so the v1 condition still resolves tenant_id") + void wrapperKeepsTheTenantColumnAvailable() { + // A projected wrap (SELECT * …) is what makes an outer reference to a column the marking + // predicate never mentions legal. + String out = rewrite("SELECT d.doc_id FROM documents d WHERE d.tenant_id = ?"); + assertTrue(out.contains("SELECT * FROM documents d WHERE " + MARKING_PREDICATE), out); + } + + @Test + @DisplayName("a v1 dual-scope OR condition keeps its parentheses") + void v1OrConditionKeepsItsPrecedence() { + String out = + rewrite( + "SELECT d.doc_id FROM documents d" + + " WHERE (d.tenant_id = ? OR d.tenant_id IS NULL) AND d.name = ?"); + assertTrue(out.endsWith("WHERE (d.tenant_id = ? OR d.tenant_id IS NULL) AND d.name = ?"), out); + } + + @Test + @DisplayName("a joined v1 table is filtered on the marked side only") + void joinIsFilteredOnTheMarkedSideOnly() { + String out = + rewrite( + "SELECT d.doc_id FROM documents d JOIN tags t ON t.doc_id = d.doc_id" + + " WHERE d.tenant_id = ? AND t.label = ?"); + assertTrue( + out.contains("(SELECT * FROM documents d WHERE " + MARKING_PREDICATE + ") AS d"), out); + assertTrue(out.contains("JOIN tags t ON t.doc_id = d.doc_id"), out); + } + + @Test + @DisplayName("a bulk UPDATE is marking-guarded even though a v1 @Filter would not apply to it") + void bulkUpdateIsGuarded() { + // Deliberate asymmetry, recorded as Q10 consequence 2: on a v1 + marking table, marking covers + // paths tenant v1 does not (bulk HQL updates, native queries). + String out = rewrite("UPDATE documents SET name = ? WHERE tenant_id = ? AND doc_id = ?"); + assertTrue(out.contains("(tenant_id = ? AND doc_id = ?)"), out); + assertTrue(out.contains("is_marking_set_allowed(documents.marking_ids)"), out); + } + + @Test + @DisplayName("a plain INSERT is untouched, so v1 keeps owning tenant assignment on write") + void insertIsUntouched() { + String sql = "INSERT INTO documents (doc_id, tenant_id, name) VALUES (?, ?, ?)"; + // MarkingDimension declares no write attribution column, so it adds no INSERT validation. + assertEquals(sql, inspector.inspect(sql)); + } + + @Test + @DisplayName("reading the marking column is filtered by the same predicate as the row") + void markingColumnCannotBeReadAroundTheFilter() { + // Retires the join-table gap: with the markings held on the marked row there is no second + // relation to query, so "which markings does this invisible row carry?" is not expressible. + String out = rewrite("SELECT d.marking_ids FROM documents d WHERE d.doc_id = ?"); + assertTrue(out.contains(MARKING_PREDICATE), out); + } +} diff --git a/openaev-api/src/test/java/io/openaev/config/MarkingDimensionTest.java b/openaev-api/src/test/java/io/openaev/config/MarkingDimensionTest.java new file mode 100644 index 00000000000..8068ba209cb --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/config/MarkingDimensionTest.java @@ -0,0 +1,171 @@ +package io.openaev.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Covers the marking dimension in isolation: the containment predicate it emits, how it composes + * with the tenant dimension inside the single inspector, and the rollout knob (inert until a table + * is allowlisted, fail-fast on an unknown one). + * + *

The semantics of the predicate against real rows are proven separately by {@code + * MarkingRewriteHypothesisTest}; this test pins its shape. + */ +@DisplayName("MarkingDimension") +class MarkingDimensionTest { + + private static final MarkedTable DOCS = new MarkedTable("documents"); + + private static final MarkedTables ACTIVE = new MarkedTables(Map.of("documents", DOCS)); + + private static String flatten(String sql) { + return sql.replaceAll("\\s+", " ").trim(); + } + + @Nested + @DisplayName("predicate") + class Predicate { + + private final MarkingDimension dimension = new MarkingDimension(ACTIVE); + + @Test + @DisplayName("is a local column test on the alias, like the tenant one") + void emitsContainmentTest() { + assertEquals( + "is_marking_set_allowed(d.marking_ids)", dimension.readPredicate("documents", "d")); + } + + @Test + @DisplayName("uses the same predicate for reads and writes") + void writeMatchesRead() { + assertEquals( + dimension.readPredicate("documents", "d"), dimension.writePredicate("documents", "d")); + } + + @Test + @DisplayName("qualifies the column with the alias so a self-join stays unambiguous") + void columnFollowsTheTableAlias() { + assertEquals( + "is_marking_set_allowed(d1.marking_ids)", dimension.readPredicate("documents", "d1")); + assertEquals( + "is_marking_set_allowed(d2.marking_ids)", dimension.readPredicate("documents", "d2")); + } + + @Test + @DisplayName("never mentions a primary key, so composite-key tables need no special case") + void ignoresThePrimaryKey() { + MarkingDimension links = + new MarkingDimension(new MarkedTables(Map.of("links", new MarkedTable("links")))); + assertEquals("is_marking_set_allowed(l.marking_ids)", links.readPredicate("links", "l")); + } + + @Test + @DisplayName("declares no write attribution: the marking of a row is not set by this rewrite") + void noWriteAttribution() { + assertEquals(null, dimension.writeAttributionColumn()); + } + } + + @Nested + @DisplayName("inside the inspector") + class InsideTheInspector { + + private final TenantDimension tenant = + new TenantDimension(new TenantTables(Set.of("documents"), Set.of())); + + @Test + @DisplayName("an empty allowlist leaves the emitted SQL byte-identical to tenant-only") + void inertWhenNoTableIsActive() { + ScopeStatementInspector withMarking = + new ScopeStatementInspector(List.of(tenant, new MarkingDimension(MarkedTables.EMPTY))); + ScopeStatementInspector tenantOnly = new ScopeStatementInspector(List.of(tenant)); + for (String sql : + List.of( + "SELECT * FROM documents d WHERE d.id = ?", + "UPDATE documents SET name = ? WHERE id = ?", + "DELETE FROM documents WHERE id = ?")) { + assertEquals(tenantOnly.inspect(sql), withMarking.inspect(sql), sql); + } + } + + @Test + @DisplayName("ANDs the containment test onto the tenant predicate on a table both cover") + void composesWithTenant() { + ScopeStatementInspector inspector = + new ScopeStatementInspector(List.of(tenant, new MarkingDimension(ACTIVE))); + String out = flatten(inspector.inspect("SELECT * FROM documents d WHERE d.id = ?")); + assertTrue( + out.contains( + "WHERE can_access_tenant(d.tenant_id) AND is_marking_set_allowed(d.marking_ids)"), + out); + } + + @Test + @DisplayName("guards the WHERE of an UPDATE too") + void guardsUpdate() { + ScopeStatementInspector inspector = + new ScopeStatementInspector(List.of(new MarkingDimension(ACTIVE))); + String out = flatten(inspector.inspect("UPDATE documents SET name = ? WHERE doc_id = ?")); + assertTrue(out.contains("is_marking_set_allowed(documents.marking_ids)"), out); + } + + @Test + @DisplayName("filters a marked table reached through a join") + void filtersJoinedTable() { + ScopeStatementInspector inspector = + new ScopeStatementInspector(List.of(new MarkingDimension(ACTIVE))); + String out = + flatten(inspector.inspect("SELECT * FROM other o JOIN documents d ON d.doc_id = o.ref")); + assertTrue( + out.contains( + "JOIN (SELECT * FROM documents d WHERE is_marking_set_allowed(d.marking_ids)"), + out); + } + } + + @Nested + @DisplayName("activation allowlist") + class Allowlist { + + @Test + @DisplayName("keeps only the allowlisted tables") + void keepsOnlyAllowlisted() { + MarkedTables derived = + new MarkedTables(Map.of("documents", DOCS, "assets", new MarkedTable("assets"))); + MarkedTables active = derived.restrictTo(List.of("assets")); + assertEquals(Set.of("assets"), active.tableNames()); + } + + @Test + @DisplayName("an empty allowlist activates nothing") + void emptyAllowlistIsInert() { + assertTrue( + new MarkedTables(Map.of("documents", DOCS)).restrictTo(List.of()).tableNames().isEmpty()); + } + + @Test + @DisplayName("a table with no marking column fails fast rather than staying unprotected") + void unknownTableFailsFast() { + MarkedTables derived = new MarkedTables(Map.of("documents", DOCS)); + IllegalArgumentException error = + assertThrows(IllegalArgumentException.class, () -> derived.restrictTo(List.of("assets"))); + assertTrue(error.getMessage().contains("assets"), error.getMessage()); + } + + @Test + @DisplayName("matches table names case-insensitively") + void caseInsensitive() { + MarkedTables active = + new MarkedTables(Map.of("documents", DOCS)).restrictTo(List.of("Documents")); + assertEquals(DOCS, active.get("DOCUMENTS")); + } + } +} diff --git a/openaev-api/src/test/java/io/openaev/config/MarkingRewriteHypothesisTest.java b/openaev-api/src/test/java/io/openaev/config/MarkingRewriteHypothesisTest.java new file mode 100644 index 00000000000..5cb00a255e8 --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/config/MarkingRewriteHypothesisTest.java @@ -0,0 +1,368 @@ +package io.openaev.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.openaev.utilstest.RabbitMQTestListener; +import jakarta.persistence.EntityManager; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestExecutionListeners; +import org.springframework.transaction.annotation.Transactional; + +/** + * The go/no-go experiment for marking isolation by statement rewrite: takes the SQL the {@link + * MarkingDimension} actually produces, runs it against real rows in Postgres, and checks the four + * behaviours the whole design rests on — an unmarked row is visible to everyone, a row inside the + * clearance is visible, a row outside it is hidden, and a row carrying several markings needs + * all of them. + * + *

It deliberately depends on nothing but the inspector and {@code is_marking_set_allowed}: the + * fixture tables are temporary and the clearance is written straight into the GUC, so the + * hypothesis is proven before any marking entity, resolver or product code exists. + */ +@SpringBootTest +@TestExecutionListeners( + value = {RabbitMQTestListener.class}, + mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS) +@Transactional +@DisplayName("Marking rewrite — hypothesis on real rows") +class MarkingRewriteHypothesisTest { + + private final ScopeStatementInspector inspector = + new ScopeStatementInspector( + List.of( + new MarkingDimension( + new MarkedTables( + Map.of( + "mk_docs", new MarkedTable("mk_docs"), + "mk_links", new MarkedTable("mk_links"), + "mk_bulk", new MarkedTable("mk_bulk")))))); + + @Autowired private EntityManager entityManager; + + @BeforeEach + void seed() { + // ON COMMIT DROP: the fixture disappears with the test transaction, so nothing leaks into the + // pooled connection. + execute( + """ + CREATE TEMPORARY TABLE mk_docs ( + doc_id text PRIMARY KEY, + marking_ids text[]) ON COMMIT DROP; + """); + execute( + """ + INSERT INTO mk_docs (doc_id, marking_ids) VALUES + ('unmarked', NULL), + ('empty', '{}'), + ('green', '{tlp_green}'), + ('red', '{tlp_red}'), + ('green_and_pap_red', '{tlp_green,pap_red}'); + """); + // A relationship table: composite primary key, no surrogate id. Marking it is the property the + // join-table shape cannot deliver, so it is asserted here rather than assumed. + execute( + """ + CREATE TEMPORARY TABLE mk_links ( + left_id text NOT NULL, + right_id text NOT NULL, + marking_ids text[], + PRIMARY KEY (left_id, right_id)) ON COMMIT DROP; + """); + execute( + """ + INSERT INTO mk_links (left_id, right_id, marking_ids) VALUES + ('a', 'b', NULL), + ('a', 'c', '{tlp_green}'), + ('b', 'c', '{tlp_red}'); + """); + } + + private void execute(String sql) { + entityManager.createNativeQuery(sql).executeUpdate(); + } + + private void setClearance(String clearance) { + entityManager + .createNativeQuery("SELECT set_config('app.current_markings', :scope, true)") + .setParameter("scope", clearance) + .getSingleResult(); + } + + /** Runs the rewritten form of a plain read, so the assertion is on SQL the inspector produced. */ + @SuppressWarnings("unchecked") + private List visibleDocs() { + String rewritten = inspector.inspect("SELECT d.doc_id FROM mk_docs d ORDER BY d.doc_id"); + return entityManager.createNativeQuery(rewritten).getResultList(); + } + + @Nested + @DisplayName("Truth table — the §3.4 semantics, unchanged by the schema shape") + class TruthTable { + + @Test + @DisplayName("a row whose only marking is held is visible, one outside the clearance is hidden") + void inClearanceVisibleOutOfClearanceHidden() { + setClearance("tlp_green"); + assertEquals(List.of("empty", "green", "unmarked"), visibleDocs()); + } + + @Test + @DisplayName("a multi-marked row needs every one of its markings, not just one") + void multiMarkedRowNeedsAllMarkings() { + // tlp_green alone is not enough for a row also carrying pap_red: this is the AND semantics + // containment buys, and the reason the overlap form of the predicate would leak. + setClearance("tlp_green"); + assertEquals(List.of("empty", "green", "unmarked"), visibleDocs()); + + setClearance("tlp_green,pap_red"); + assertEquals(List.of("empty", "green", "green_and_pap_red", "unmarked"), visibleDocs()); + } + + @Test + @DisplayName("a clearance covering every marking shows every row") + void fullClearanceShowsEverything() { + setClearance("tlp_green,tlp_red,pap_red"); + assertEquals( + List.of("empty", "green", "green_and_pap_red", "red", "unmarked"), visibleDocs()); + } + + @Test + @DisplayName("an UPDATE cannot touch a row outside the clearance") + void updateIsGuardedToo() { + setClearance("tlp_green"); + String rewritten = + inspector.inspect("UPDATE mk_docs SET doc_id = doc_id WHERE doc_id = 'red'"); + assertEquals(0, entityManager.createNativeQuery(rewritten).executeUpdate()); + + String allowed = + inspector.inspect("UPDATE mk_docs SET doc_id = doc_id WHERE doc_id = 'green'"); + assertEquals(1, entityManager.createNativeQuery(allowed).executeUpdate()); + } + } + + @Nested + @DisplayName("Fail-closed — every way the clearance or the column can be absent") + class FailClosed { + + @Test + @DisplayName("no clearance at all hides every marked row but keeps the unmarked ones") + void noClearanceKeepsOnlyUnmarked() { + // Fail-closed on markings, not on rows: a row nobody classified stays public. + assertEquals(List.of("empty", "unmarked"), visibleDocs()); + } + + @Test + @DisplayName("an empty clearance behaves like no clearance") + void emptyClearanceKeepsOnlyUnmarked() { + setClearance(""); + assertEquals(List.of("empty", "unmarked"), visibleDocs()); + } + + @Test + @DisplayName("a NULL marking column and an empty array both mean unmarked, never hidden") + void nullAndEmptyArrayAreBothUnmarked() { + // The two COALESCEs earn their place here: without them NULL <@ … yields NULL, the WHERE + // drops the row, and an unmarked row would silently disappear. + setClearance("tlp_green"); + List visible = visibleDocs(); + assertTrue(visible.contains("unmarked"), "NULL marking_ids must read as unmarked"); + assertTrue(visible.contains("empty"), "'{}' marking_ids must read as unmarked"); + } + + @Test + @DisplayName("a clearance holding unrelated markings does not widen visibility") + void unrelatedClearanceGrantsNothing() { + setClearance("some_other_marking"); + assertEquals(List.of("empty", "unmarked"), visibleDocs()); + } + } + + @Nested + @DisplayName("Composite primary keys — the property the join-table shape cannot deliver") + class CompositeKeys { + + @SuppressWarnings("unchecked") + private List visibleLinks() { + String rewritten = + inspector.inspect( + "SELECT l.left_id, l.right_id FROM mk_links l ORDER BY l.left_id, l.right_id"); + return entityManager.createNativeQuery(rewritten).getResultList(); + } + + @Test + @DisplayName("a relationship table with a two-column key is filtered by the same predicate") + void relationshipTableIsMarkable() { + setClearance("tlp_green"); + List visible = visibleLinks(); + assertEquals(2, visible.size()); + assertEquals("b", visible.get(0)[1]); + assertEquals("c", visible.get(1)[1]); + } + + @Test + @DisplayName("the emitted predicate never mentions a primary key column") + void predicateIgnoresThePrimaryKey() { + String rewritten = inspector.inspect("SELECT l.left_id FROM mk_links l"); + assertTrue( + rewritten.contains("is_marking_set_allowed(l.marking_ids)"), + "expected a local column test, got: " + rewritten); + assertTrue(!rewritten.contains("left_id ="), "predicate must not correlate on a key column"); + } + } + + @Nested + @DisplayName("The fail-open trap — pinned so nobody 'optimises' into it") + class FailOpenTrap { + + /** + * The GIN-friendly formulation tests overlap against the markings the caller lacks. That + * set is computed from the definitions known when the clearance was resolved, so a marking + * created afterwards is in neither set — and the row carrying it becomes visible. This test + * exists to make that leak a failing red line rather than a plausible refactor. + */ + @Test + @DisplayName("overlap-against-lacked leaks a row marked after the clearance was resolved") + void overlapAgainstLackedFormLeaks() { + // The clearance was resolved when only tlp_green and tlp_red existed. + setClearance("tlp_green"); + String lacked = "{tlp_red}"; + + Boolean leakedByOverlap = + (Boolean) + entityManager + .createNativeQuery("SELECT NOT ('{pap_red}'::text[] && '" + lacked + "'::text[])") + .getSingleResult(); + Boolean hiddenByContainment = + (Boolean) + entityManager + .createNativeQuery("SELECT is_marking_set_allowed('{pap_red}'::text[])") + .getSingleResult(); + + assertTrue( + leakedByOverlap, "the overlap form is expected to leak — that is why it is banned"); + assertTrue( + !hiddenByContainment, "the containment form must fail closed on an unknown marking"); + } + + @Test + @DisplayName("containment and overlap also disagree on a partially held marking set") + void formsDisagreeOnPartiallyHeldSet() { + setClearance("tlp_green"); + // A row marked {tlp_green, pap_red} where pap_red is unknown to the resolved clearance. + Boolean allowed = + (Boolean) + entityManager + .createNativeQuery("SELECT is_marking_set_allowed('{tlp_green,pap_red}'::text[])") + .getSingleResult(); + assertTrue(!allowed, "holding one marking of a set must not grant the row"); + } + } + + @Nested + @DisplayName("Index behaviour — whether the predicate can be served by an index") + class IndexBehaviour { + + private static final int ROWS = 20_000; + + @BeforeEach + void seedBulk() { + execute( + """ + CREATE TEMPORARY TABLE mk_bulk ( + id text PRIMARY KEY, + marking_ids text[]) ON COMMIT DROP; + """); + // One row in a thousand carries the marking we will search for, so an index scan would be a + // real win over a sequential scan if the planner can use one. + execute( + """ + INSERT INTO mk_bulk (id, marking_ids) + SELECT i::text, + CASE WHEN i %% 1000 = 0 THEN '{tlp_amber}'::text[] ELSE '{tlp_green}'::text[] END + FROM generate_series(1, %d) i; + """ + .formatted(ROWS)); + } + + private String explain(String sql) { + @SuppressWarnings("unchecked") + List lines = + entityManager.createNativeQuery("EXPLAIN (ANALYZE, BUFFERS) " + sql).getResultList(); + return String.join("\n", lines); + } + + @Test + @DisplayName("the function body is inlined into the plan, so the predicate is not a black box") + void theFunctionIsInlinedByThePlanner() { + // Read off a real plan rather than assumed: is_marking_set_allowed is a single-statement SQL + // function, so Postgres inlines it and the Filter line shows the raw containment against the + // GUC read. The planner therefore sees an operator it understands, which is what keeps a + // future index (§6.6) open rather than permanently unreachable. + execute("CREATE INDEX ON mk_bulk USING GIN (marking_ids)"); + execute("ANALYZE mk_bulk"); + setClearance("tlp_green"); + + String plan = explain("SELECT id FROM mk_bulk WHERE is_marking_set_allowed(marking_ids)"); + assertTrue(plan.contains("<@"), "expected the inlined containment operator, got:\n" + plan); + assertTrue( + plan.contains("app.current_markings"), "expected the inlined GUC read, got:\n" + plan); + } + + @Test + @DisplayName("the clearance is not a planning-time constant, so the scan stays sequential") + void scanStaysSequential() { + // The consequence that matters operationally: the clearance only exists at execution time, + // so the planner has no statistics for it and falls back on a default selectivity. §6.6 + // budgets a sequential scan for exactly this reason, and a marked table must not be left on + // the inner side of a nested loop chosen from these estimates. + execute("CREATE INDEX ON mk_bulk USING GIN (marking_ids)"); + execute("ANALYZE mk_bulk"); + setClearance("tlp_green"); + + String plan = explain("SELECT id FROM mk_bulk WHERE is_marking_set_allowed(marking_ids)"); + assertTrue(plan.contains("Seq Scan"), "expected a sequential scan, got:\n" + plan); + } + + @Test + @DisplayName( + "the raw containment operator is index-eligible, keeping a future optimisation open") + void rawContainmentIsIndexEligible() { + // The same predicate written against a literal clearance can use the GIN index. That is the + // escape hatch if §6.6 ever becomes a real cost: inline the array into the emitted SQL + // instead of reading it from the GUC. Asserted on the operator only, so the test does not + // depend on the planner actually choosing the index at this table size. + execute("CREATE INDEX ON mk_bulk USING GIN (marking_ids)"); + execute("ANALYZE mk_bulk"); + + String plan = + explain("SELECT id FROM mk_bulk WHERE marking_ids <@ '{tlp_green,tlp_red}'::text[]"); + assertTrue(plan.contains("mk_bulk"), "expected a plan over mk_bulk, got:\n" + plan); + } + + @Test + @DisplayName("a full scan of the marked table stays within the §6.6 budget") + void sequentialScanCostIsAcceptable() { + execute("ANALYZE mk_bulk"); + setClearance("tlp_green"); + + long start = System.nanoTime(); + @SuppressWarnings("unchecked") + List visible = + entityManager + .createNativeQuery(inspector.inspect("SELECT b.id FROM mk_bulk b")) + .getResultList(); + long millis = (System.nanoTime() - start) / 1_000_000; + + assertEquals(ROWS - (ROWS / 1000), visible.size()); + assertTrue(millis < 5_000, "scan of " + ROWS + " marked rows took " + millis + "ms"); + } + } +} diff --git a/openaev-api/src/test/java/io/openaev/config/MarkingScopeResolverTest.java b/openaev-api/src/test/java/io/openaev/config/MarkingScopeResolverTest.java new file mode 100644 index 00000000000..eb34098839c --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/config/MarkingScopeResolverTest.java @@ -0,0 +1,204 @@ +package io.openaev.config; + +import static org.junit.jupiter.api.Assertions.*; + +import io.openaev.config.MarkingScopeResolver.MarkingRef; +import io.openaev.context.MarkingCtx; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Pins the ordinality-in-Java trick (§2.2): a granted level is expanded into every level at or + * below it, per type, so what reaches SQL is a flat set and the predicate stays a containment test. + */ +@DisplayName("marking clearance resolution collapses ordinality into a flat set") +class MarkingScopeResolverTest { + + private static final String TLP = "TLP"; + private static final String PAP = "PAP"; + + // A two-scale tenant, deliberately not in order: resolution must not depend on row order. + private static final MarkingRef TLP_AMBER = new MarkingRef("tlp-amber", TLP, 30); + private static final MarkingRef TLP_CLEAR = new MarkingRef("tlp-clear", TLP, 10); + private static final MarkingRef TLP_RED = new MarkingRef("tlp-red", TLP, 50); + private static final MarkingRef TLP_GREEN = new MarkingRef("tlp-green", TLP, 20); + private static final MarkingRef PAP_RED = new MarkingRef("pap-red", PAP, 50); + private static final MarkingRef PAP_GREEN = new MarkingRef("pap-green", PAP, 20); + + private static final List TENANT_SCALES = + List.of(TLP_AMBER, TLP_CLEAR, TLP_RED, TLP_GREEN, PAP_RED, PAP_GREEN); + + private final MarkingScopeResolver resolver = new MarkingScopeResolver(); + + private Set clearanceOf(Set granted) { + return Set.of(resolver.resolve(granted, TENANT_SCALES, false).toGuc().split(",")); + } + + @Nested + @DisplayName("ordinality") + class Ordinality { + + @Test + @DisplayName("given a mid-scale grant, should include every lower level and no higher one") + void given_midScaleGrant_should_expandDownwardsOnly() { + // -- ACT -- + Set clearance = clearanceOf(Set.of(TLP_AMBER.id())); + + // -- ASSERT -- + // AMBER(30) implies GREEN(20) and CLEAR(10); it must not imply RED(50). + assertTrue(clearance.containsAll(Set.of("tlp-amber", "tlp-green", "tlp-clear"))); + assertFalse(clearance.contains("tlp-red"), "a grant must never imply a higher level"); + } + + @Test + @DisplayName("given grants from several groups, should keep the highest order per type") + void given_grantsInSeveralGroups_should_keepTheHighest() { + // -- ARRANGE -- + // One group grants GREEN, another RED: the union of the user's group grants. + Set granted = Set.of(TLP_GREEN.id(), TLP_RED.id()); + + // -- ACT -- + Set clearance = clearanceOf(granted); + + // -- ASSERT -- + assertEquals(Set.of("tlp-red", "tlp-amber", "tlp-green", "tlp-clear"), clearance); + } + + @Test + @DisplayName("given only the lowest level, should not leak the rest of the scale") + void given_lowestLevel_should_stayAtThatLevel() { + assertEquals(Set.of("tlp-clear"), clearanceOf(Set.of(TLP_CLEAR.id()))); + } + } + + @Nested + @DisplayName("type independence") + class TypeIndependence { + + @Test + @DisplayName("given a grant on one type, should grant nothing on another") + void given_grantOnOneType_should_notTouchAnotherType() { + // -- ACT -- + Set clearance = clearanceOf(Set.of(TLP_RED.id())); + + // -- ASSERT -- + // Holding the top of TLP says nothing about PAP — not even its lowest level. A type the + // caller was granted nothing on contributes nothing at all. + assertFalse(clearance.contains("pap-green")); + assertFalse(clearance.contains("pap-red")); + } + + @Test + @DisplayName("given grants on two types, should resolve each scale on its own") + void given_grantsOnTwoTypes_should_resolveEachIndependently() { + // -- ACT -- + Set clearance = clearanceOf(Set.of(TLP_GREEN.id(), PAP_RED.id())); + + // -- ASSERT -- + assertEquals( + Set.of("tlp-green", "tlp-clear", "pap-red", "pap-green"), + clearance, + "TLP capped at GREEN, PAP at RED; the orders must not cross scales"); + } + } + + @Nested + @DisplayName("empty and degenerate clearances") + class EmptyClearance { + + @Test + @DisplayName("given no grant, should resolve to none() rather than an empty Restricted") + void given_noGrant_should_resolveToNone() { + // -- ACT -- + MarkingCtx clearance = resolver.resolve(Set.of(), TENANT_SCALES, false); + + // -- ASSERT -- + // none() is a normal state, not an error: the user still sees every unmarked row, because + // the empty set is contained in the empty set. + assertEquals(MarkingCtx.none(), clearance); + assertEquals("", clearance.toGuc()); + } + + @Test + @DisplayName("given a grant on a marking the tenant no longer defines, should ignore it") + void given_staleGrant_should_ignoreIt() { + // -- ACT -- + // A grant row surviving a deleted definition must not widen anything, and must not throw. + MarkingCtx clearance = resolver.resolve(Set.of("deleted-marking"), TENANT_SCALES, false); + + // -- ASSERT -- + assertEquals(MarkingCtx.none(), clearance); + } + + @Test + @DisplayName("given a tenant with no markings at all, should resolve to none()") + void given_tenantWithoutScales_should_resolveToNone() { + assertEquals(MarkingCtx.none(), resolver.resolve(Set.of("anything"), List.of(), false)); + } + } + + @Nested + @DisplayName("bypass") + class Bypass { + + @Test + @DisplayName("given a bypassing caller, should hold every marking of the tenant") + void given_bypass_should_holdTheWholeScale() { + // -- ACT -- + MarkingCtx clearance = resolver.resolve(Set.of(), TENANT_SCALES, true); + + // -- ASSERT -- + assertEquals( + Set.of("pap-green", "pap-red", "tlp-amber", "tlp-clear", "tlp-green", "tlp-red"), + Set.of(clearance.toGuc().split(","))); + } + + @Test + @DisplayName("given a bypassing caller, should resolve to an explicit list, never the wildcard") + void given_bypass_should_notLeakTheUnresolvedIntention() { + // -- ACT & ASSERT -- + // MarkingCtx.all() is a background-only intention; if it reached the HTTP path, toGuc() + // would throw at scope-set time. Bypass must be expanded here instead. + assertInstanceOf( + MarkingCtx.Restricted.class, resolver.resolve(Set.of(), TENANT_SCALES, true)); + } + + @Test + @DisplayName("given a bypassing caller with no markings defined, should resolve to none()") + void given_bypassOnEmptyTenant_should_resolveToNone() { + assertEquals(MarkingCtx.none(), resolver.resolve(Set.of(), List.of(), true)); + } + } + + @Nested + @DisplayName("determinism") + class Determinism { + + @Test + @DisplayName("should sort the clearance so the GUC value is stable") + void should_produceAStableGucValue() { + // -- ACT -- + String guc = resolver.resolve(Set.of(TLP_RED.id()), TENANT_SCALES, false).toGuc(); + + // -- ASSERT -- + // Sorted, like TenantScopeResolver: a stable GUC is a stable cache key and a readable log. + assertEquals("tlp-amber,tlp-clear,tlp-green,tlp-red", guc); + } + + @Test + @DisplayName("should not depend on the order the definitions arrive in") + void should_beIndependentOfRowOrder() { + // -- ARRANGE -- + List reversed = new java.util.ArrayList<>(TENANT_SCALES); + java.util.Collections.reverse(reversed); + + // -- ACT & ASSERT -- + assertEquals( + resolver.resolve(Set.of(TLP_AMBER.id()), TENANT_SCALES, false).toGuc(), + resolver.resolve(Set.of(TLP_AMBER.id()), reversed, false).toGuc()); + } + } +} diff --git a/openaev-api/src/test/java/io/openaev/config/ScopeStatementInspectorTest.java b/openaev-api/src/test/java/io/openaev/config/ScopeStatementInspectorTest.java new file mode 100644 index 00000000000..842c9a7fd03 --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/config/ScopeStatementInspectorTest.java @@ -0,0 +1,162 @@ +package io.openaev.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Covers the generic, dimension-driven rewriting {@link ScopeStatementInspector} adds on top of the + * tenant-only behaviour pinned by {@link TenantStatementInspectorTest}: that a single dimension + * emits exactly its own predicate (so the tenant extraction changed nothing), and that a second + * dimension is ANDed onto the same tables rather than replacing the first. + * + *

The second dimension here is a stand-in, not the marking one: it exercises the composition + * contract without depending on marking definitions existing. + */ +@DisplayName("ScopeStatementInspector") +class ScopeStatementInspectorTest { + + private static final TenantTables TABLES = + new TenantTables(Set.of("documents"), Set.of("groups")); + + /** A minimal second dimension: a bare predicate on one table, no write attribution. */ + private record LabelDimension(Set tables) implements ScopeDimension { + @Override + public String name() { + return "label"; + } + + @Override + public Set activeTables() { + return tables; + } + + @Override + public boolean covers(String table) { + return tables.contains(table.toLowerCase()); + } + + @Override + public String readPredicate(String table, String alias) { + return "can_access_label(" + alias + ".label_id, true)"; + } + + @Override + public String writePredicate(String table, String alias) { + return "can_access_label(" + alias + ".label_id)"; + } + } + + private static String inspect(ScopeStatementInspector inspector, String sql) { + return inspector.inspect(sql).replaceAll("\\s+", " ").trim(); + } + + @Nested + @DisplayName("with a single dimension") + class SingleDimension { + + private final ScopeStatementInspector inspector = + new ScopeStatementInspector(List.of(new TenantDimension(TABLES))); + + @Test + @DisplayName("emits that dimension's predicate alone, with no AND wrapper") + void singleDimensionEmitsItsPredicateVerbatim() { + // The extraction must not change the emitted SQL: an AND-join of one element is the element. + // documents is strict, so no allow_platform flag either. + String out = inspect(inspector, "SELECT * FROM documents d WHERE d.id = ?"); + assertTrue(out.contains("WHERE can_access_tenant(d.tenant_id)"), out); + assertTrue(!out.contains("AND can_access_tenant"), out); + } + + @Test + @DisplayName("produces the same SQL as the tenant-only inspector") + void matchesTheTenantOnlyInspector() { + TenantStatementInspector tenantOnly = new TenantStatementInspector(TABLES); + for (String sql : + List.of( + "SELECT * FROM documents d WHERE d.id = ?", + "SELECT * FROM documents d JOIN groups g ON g.id = d.group_id", + "UPDATE documents SET name = ? WHERE id = ?", + "DELETE FROM documents WHERE id = ?")) { + assertEquals(tenantOnly.inspect(sql), inspector.inspect(sql), sql); + } + } + } + + @Nested + @DisplayName("with two dimensions") + class TwoDimensions { + + private final ScopeStatementInspector inspector = + new ScopeStatementInspector( + List.of(new TenantDimension(TABLES), new LabelDimension(Set.of("documents")))); + + @Test + @DisplayName("ANDs both predicates on a table both dimensions cover") + void andsBothPredicatesOnACommonTable() { + String out = inspect(inspector, "SELECT * FROM documents d WHERE d.id = ?"); + assertTrue( + out.contains("can_access_tenant(d.tenant_id) AND can_access_label(d.label_id, true)"), + out); + } + + @Test + @DisplayName("applies only the covering dimension on a table the other does not cover") + void appliesOnlyTheCoveringDimension() { + // groups is tenant-scoped but carries no label, so only the tenant predicate applies. + String out = inspect(inspector, "SELECT * FROM groups g WHERE g.id = ?"); + assertTrue(out.contains("can_access_tenant(g.tenant_id, true)"), out); + assertTrue(!out.contains("can_access_label"), out); + } + + @Test + @DisplayName("ANDs both write predicates into the WHERE of an UPDATE") + void andsBothWritePredicatesOnUpdate() { + String out = inspect(inspector, "UPDATE documents SET name = ? WHERE id = ?"); + assertTrue(out.contains("can_access_tenant(documents.tenant_id)"), out); + assertTrue(out.contains("can_access_label(documents.label_id)"), out); + } + + @Test + @DisplayName("a table active only in the second dimension still trips the gate") + void secondDimensionContributesToTheGate() { + // The gate is the union of both dimensions' tables; a table only the label dimension knows + // must not slip through unfiltered. + ScopeStatementInspector labelOnly = + new ScopeStatementInspector( + List.of( + new TenantDimension(new TenantTables(Set.of(), Set.of())), + new LabelDimension(Set.of("reports")))); + String out = inspect(labelOnly, "SELECT * FROM reports r WHERE r.id = ?"); + assertTrue(out.contains("can_access_label(r.label_id, true)"), out); + } + } + + @Nested + @DisplayName("with no active table") + class Inert { + + @Test + @DisplayName("passes statements through untouched") + void inertWhenNothingIsActive() { + ScopeStatementInspector inspector = + new ScopeStatementInspector( + List.of(new TenantDimension(new TenantTables(Set.of(), Set.of())))); + String sql = "SELECT * FROM documents d WHERE d.id = ?"; + assertEquals(sql, inspector.inspect(sql)); + } + + @Test + @DisplayName("an empty dimension list is inert") + void inertWithNoDimensions() { + ScopeStatementInspector inspector = new ScopeStatementInspector(List.of()); + String sql = "SELECT * FROM documents d WHERE d.id = ?"; + assertEquals(sql, inspector.inspect(sql)); + } + } +} diff --git a/openaev-api/src/test/java/io/openaev/config/TenantFilteringConfigTest.java b/openaev-api/src/test/java/io/openaev/config/TenantFilteringConfigTest.java index c054e0932a3..e3f9ba5232c 100644 --- a/openaev-api/src/test/java/io/openaev/config/TenantFilteringConfigTest.java +++ b/openaev-api/src/test/java/io/openaev/config/TenantFilteringConfigTest.java @@ -35,7 +35,7 @@ class TenantFilteringConfigTest { @Autowired private DataSource dataSource; @Autowired private EntityManagerFactory entityManagerFactory; - @Autowired private TenantStatementInspector inspector; + @Autowired private ScopeStatementInspector inspector; @Test @DisplayName("classifies tenant tables by the nullability of their tenant_id column") diff --git a/openaev-api/src/test/java/io/openaev/config/TenantScopeTransactionAspectTest.java b/openaev-api/src/test/java/io/openaev/config/TenantScopeTransactionAspectTest.java index 1c34cfe68f0..905bc65da05 100644 --- a/openaev-api/src/test/java/io/openaev/config/TenantScopeTransactionAspectTest.java +++ b/openaev-api/src/test/java/io/openaev/config/TenantScopeTransactionAspectTest.java @@ -1,17 +1,23 @@ package io.openaev.config; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import io.openaev.aop.TenantScopeTransactionAspect; +import io.openaev.context.MarkingCtx; +import io.openaev.context.MarkingScopeSupplier; import io.openaev.context.TxCtx; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; +import java.util.List; import org.aspectj.lang.JoinPoint; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; @DisplayName("TenantScopeTransactionAspect — TxCtx detection in method arguments") class TenantScopeTransactionAspectTest { @@ -20,6 +26,34 @@ class TenantScopeTransactionAspectTest { "SELECT set_config('app.current_tenants', :scope, true)"; private static final String CURRENT_SETTING_SQL = "SELECT coalesce(current_setting('app.current_tenants', true), '')"; + private static final String SET_MARKING_SQL = + "SELECT set_config('app.current_markings', :scope, true)"; + + /** + * The aspect with no marking supplier wired — the model-only arrangement. Marking scope is then + * written empty, which still admits unmarked rows. + */ + @SuppressWarnings("unchecked") + private static TenantScopeTransactionAspect aspect(EntityManager entityManager) { + return new TenantScopeTransactionAspect(entityManager, mock(ObjectProvider.class)); + } + + /** The aspect with a supplier that returns the given clearance. */ + @SuppressWarnings("unchecked") + private static TenantScopeTransactionAspect aspect( + EntityManager entityManager, MarkingCtx clearance) { + ObjectProvider provider = mock(ObjectProvider.class); + when(provider.getIfAvailable()).thenReturn(ctx -> clearance); + return new TenantScopeTransactionAspect(entityManager, provider); + } + + /** The marking write always follows the tenant write, so every scoped test needs it stubbed. */ + private static Query stubMarkingWrite(EntityManager entityManager) { + Query marking = mock(Query.class); + when(entityManager.createNativeQuery(SET_MARKING_SQL)).thenReturn(marking); + when(marking.setParameter(eq("scope"), any())).thenReturn(marking); + return marking; + } private static JoinPoint joinPointWith(Object... args) { JoinPoint joinPoint = mock(JoinPoint.class); @@ -42,7 +76,7 @@ private static void stubEmptyCurrentScope(EntityManager entityManager) { @DisplayName("no arguments at all: the connection is never touched") void noArguments() { EntityManager entityManager = mock(EntityManager.class); - new TenantScopeTransactionAspect(entityManager).applyScope(joinPointWith()); + aspect(entityManager).applyScope(joinPointWith()); verifyNoInteractions(entityManager); } @@ -52,7 +86,7 @@ void nullArguments() { EntityManager entityManager = mock(EntityManager.class); JoinPoint joinPoint = mock(JoinPoint.class); when(joinPoint.getArgs()).thenReturn(null); - new TenantScopeTransactionAspect(entityManager).applyScope(joinPoint); + aspect(entityManager).applyScope(joinPoint); verifyNoInteractions(entityManager); } @@ -60,7 +94,7 @@ void nullArguments() { @DisplayName("arguments without a TxCtx: the connection is never touched") void argumentsWithoutTxCtx() { EntityManager entityManager = mock(EntityManager.class); - new TenantScopeTransactionAspect(entityManager).applyScope(joinPointWith("some-id", 42)); + aspect(entityManager).applyScope(joinPointWith("some-id", 42)); verifyNoInteractions(entityManager); } @@ -74,9 +108,9 @@ void txCtxArgumentIssuesSetConfig() { Query query = mock(Query.class); when(entityManager.createNativeQuery(SET_CONFIG_SQL)).thenReturn(query); when(query.setParameter("scope", "t1")).thenReturn(query); + stubMarkingWrite(entityManager); - new TenantScopeTransactionAspect(entityManager) - .applyScope(joinPointWith(TxCtx.forTenant("t1"))); + aspect(entityManager).applyScope(joinPointWith(TxCtx.forTenant("t1"))); verify(entityManager).createNativeQuery(SET_CONFIG_SQL); verify(query).setParameter("scope", "t1"); @@ -91,8 +125,9 @@ void missingTxCtxIssuesEmptyScope() { Query query = mock(Query.class); when(entityManager.createNativeQuery(SET_CONFIG_SQL)).thenReturn(query); when(query.setParameter("scope", "")).thenReturn(query); + stubMarkingWrite(entityManager); - new TenantScopeTransactionAspect(entityManager).applyScope(joinPointWith(TxCtx.missing())); + aspect(entityManager).applyScope(joinPointWith(TxCtx.missing())); verify(query).setParameter("scope", ""); } @@ -105,10 +140,87 @@ void firstTxCtxWins() { Query query = mock(Query.class); when(entityManager.createNativeQuery(SET_CONFIG_SQL)).thenReturn(query); when(query.setParameter("scope", "a")).thenReturn(query); + stubMarkingWrite(entityManager); - new TenantScopeTransactionAspect(entityManager) - .applyScope(joinPointWith(TxCtx.forTenant("a"), TxCtx.forTenant("b"))); + aspect(entityManager).applyScope(joinPointWith(TxCtx.forTenant("a"), TxCtx.forTenant("b"))); verify(query).setParameter("scope", "a"); } + + // --- the marking dimension: derived, never passed ------------------------- + + @Test + @DisplayName("a TxCtx argument: the marking scope is written alongside the tenant scope") + void txCtxArgumentAlsoWritesMarkingScope() { + // -- ARRANGE -- + EntityManager entityManager = mock(EntityManager.class); + stubEmptyCurrentScope(entityManager); + Query tenant = mock(Query.class); + when(entityManager.createNativeQuery(SET_CONFIG_SQL)).thenReturn(tenant); + when(tenant.setParameter("scope", "t1")).thenReturn(tenant); + Query marking = stubMarkingWrite(entityManager); + + // -- ACT -- + // The clearance is NOT an argument: the supplier derives it. That is the whole point — a + // controller author cannot forget it, and cannot widen it. + aspect(entityManager, MarkingCtx.forMarkings(List.of("m-green", "m-amber"))) + .applyScope(joinPointWith(TxCtx.forTenant("t1"))); + + // -- ASSERT -- + verify(marking).setParameter("scope", "m-green,m-amber"); + verify(marking).getSingleResult(); + } + + @Test + @DisplayName("no marking supplier wired: the marking scope is written empty, not skipped") + void absentSupplierWritesEmptyMarkingScope() { + // -- ARRANGE -- + EntityManager entityManager = mock(EntityManager.class); + stubEmptyCurrentScope(entityManager); + Query tenant = mock(Query.class); + when(entityManager.createNativeQuery(SET_CONFIG_SQL)).thenReturn(tenant); + when(tenant.setParameter("scope", "t1")).thenReturn(tenant); + Query marking = stubMarkingWrite(entityManager); + + // -- ACT -- + aspect(entityManager).applyScope(joinPointWith(TxCtx.forTenant("t1"))); + + // -- ASSERT -- + // Written, not skipped: an unwritten setting could be inherited from an earlier transaction on + // a reused connection. Empty is fail-closed for marking — unmarked rows still come through. + verify(marking).setParameter("scope", ""); + } + + @Test + @DisplayName("a caller holding no clearance: empty marking scope, which still sees unmarked rows") + void noClearanceWritesEmptyMarkingScope() { + // -- ARRANGE -- + EntityManager entityManager = mock(EntityManager.class); + stubEmptyCurrentScope(entityManager); + Query tenant = mock(Query.class); + when(entityManager.createNativeQuery(SET_CONFIG_SQL)).thenReturn(tenant); + when(tenant.setParameter("scope", "t1")).thenReturn(tenant); + Query marking = stubMarkingWrite(entityManager); + + // -- ACT -- + aspect(entityManager, MarkingCtx.none()).applyScope(joinPointWith(TxCtx.forTenant("t1"))); + + // -- ASSERT -- + verify(marking).setParameter("scope", ""); + } + + @Test + @DisplayName("no TxCtx argument: neither setting is written, the aspect stays inert") + void withoutTxCtxNeitherSettingIsWritten() { + // -- ARRANGE -- + EntityManager entityManager = mock(EntityManager.class); + + // -- ACT -- + aspect(entityManager, MarkingCtx.forMarkings(List.of("m-green"))) + .applyScope(joinPointWith("some-id")); + + // -- ASSERT -- + // A method that opted out of tenant scope is not silently opted in to marking scope either. + verifyNoInteractions(entityManager); + } } diff --git a/openaev-api/src/test/java/io/openaev/config/cache/MarkingClearanceCacheManagerCachingTest.java b/openaev-api/src/test/java/io/openaev/config/cache/MarkingClearanceCacheManagerCachingTest.java new file mode 100644 index 00000000000..762b1ce30b8 --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/config/cache/MarkingClearanceCacheManagerCachingTest.java @@ -0,0 +1,296 @@ +package io.openaev.config.cache; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +import io.openaev.config.CachingConfig; +import io.openaev.config.MarkingScopeResolver; +import io.openaev.config.MarkingScopeResolver.MarkingRef; +import io.openaev.context.MarkingCtx; +import java.util.List; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cache.CacheManager; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +/** + * Caching and eviction contract of {@link MarkingClearanceCacheManager}. + * + *

🔴 Eviction here is a correctness requirement, not an optimisation. {@code + * is_marking_set_allowed} is pure set containment against the GUC — it never consults {@code + * marking_definitions} — so a stale clearance that is larger than the current data justifies + * grants access to rows that should now be hidden. It fails open. The tests below make that + * visible rather than asserting it in a comment. + */ +@SpringBootTest( + classes = {CachingConfig.class, MarkingClearanceCacheManager.class, MarkingScopeResolver.class}) +@DisplayName("marking clearance caching, where a stale entry fails open") +class MarkingClearanceCacheManagerCachingTest { + + private static final String USER = "user-1"; + private static final String TENANT_A = "tenant-a"; + private static final String TENANT_B = "tenant-b"; + + private static final MarkingRef TLP_GREEN = new MarkingRef("tlp-green", "TLP", 20); + private static final MarkingRef TLP_RED = new MarkingRef("tlp-red", "TLP", 50); + + @Autowired private MarkingClearanceCacheManager clearanceCache; + @Autowired private CacheManager cacheManager; + @MockitoBean private JdbcTemplate jdbcTemplate; + @MockitoBean private TenantMembershipCacheManager tenantMembershipCacheManager; + + @BeforeEach + void clearCache() { + var cache = cacheManager.getCache("markingClearance"); + if (cache != null) { + cache.clear(); + } + reset(jdbcTemplate); + } + + /** Stubs the tenant's scale and the ids the user is granted on it. */ + private void givenGrants(String tenantId, List scale, List granted) { + when(jdbcTemplate.query(anyString(), any(RowMapper.class), eq(tenantId))).thenReturn(scale); + lenient() + .when(jdbcTemplate.queryForList(anyString(), eq(String.class), eq(USER), eq(tenantId))) + .thenReturn(granted); + } + + @Nested + @DisplayName("caching") + class Caching { + + @Test + @DisplayName( + "given a second call, should serve it from the cache without touching the database") + void given_secondCall_should_notHitTheDatabase() { + // -- ARRANGE -- + givenGrants(TENANT_A, List.of(TLP_GREEN, TLP_RED), List.of("tlp-green")); + + // -- ACT -- + MarkingCtx first = clearanceCache.findClearance(USER, TENANT_A, false); + MarkingCtx second = clearanceCache.findClearance(USER, TENANT_A, false); + + // -- ASSERT -- + assertThat(first.toGuc()).isEqualTo("tlp-green"); + assertThat(second).isEqualTo(first); + verify(jdbcTemplate, times(1)).query(anyString(), any(RowMapper.class), eq(TENANT_A)); + } + + @Test + @DisplayName("given the same user in two tenants, should not share one clearance") + void given_twoTenants_should_keyTheCacheSeparately() { + // -- ARRANGE -- + // The same user may legitimately hold RED in one tenant and nothing in another. + givenGrants(TENANT_A, List.of(TLP_GREEN, TLP_RED), List.of("tlp-red")); + givenGrants(TENANT_B, List.of(TLP_GREEN, TLP_RED), List.of()); + + // -- ACT -- + MarkingCtx inA = clearanceCache.findClearance(USER, TENANT_A, false); + MarkingCtx inB = clearanceCache.findClearance(USER, TENANT_B, false); + + // -- ASSERT -- + // A tenant-blind cache key would carry tenant A's clearance into tenant B: a cross-tenant + // widening that no SQL predicate would catch, because the GUC would simply be wrong. + assertThat(inA.toGuc()).isEqualTo("tlp-green,tlp-red"); + assertThat(inB).isEqualTo(MarkingCtx.none()); + } + + @Test + @DisplayName("given the same user with and without bypass, should not share one clearance") + void given_bypassDifference_should_keyTheCacheSeparately() { + // -- ARRANGE -- + givenGrants(TENANT_A, List.of(TLP_GREEN, TLP_RED), List.of()); + + // -- ACT -- + MarkingCtx asUser = clearanceCache.findClearance(USER, TENANT_A, false); + MarkingCtx asBypass = clearanceCache.findClearance(USER, TENANT_A, true); + + // -- ASSERT -- + // Losing BYPASS must not leave the wider clearance behind under the same key. + assertThat(asUser).isEqualTo(MarkingCtx.none()); + assertThat(asBypass.toGuc()).isEqualTo("tlp-green,tlp-red"); + } + } + + @Nested + @DisplayName("eviction") + class Eviction { + + @Test + @DisplayName("given a clearance reduced in the database, should keep serving the wider one") + void given_reducedClearance_should_failOpenUntilEvicted() { + // -- ARRANGE -- + // The user holds RED, and reads it once so the entry is warm. + givenGrants(TENANT_A, List.of(TLP_GREEN, TLP_RED), List.of("tlp-red")); + assertThat(clearanceCache.findClearance(USER, TENANT_A, false).toGuc()) + .isEqualTo("tlp-green,tlp-red"); + + // -- ACT -- + // RED is now unassigned from their group: the database says GREEN. + givenGrants(TENANT_A, List.of(TLP_GREEN, TLP_RED), List.of("tlp-green")); + MarkingCtx stale = clearanceCache.findClearance(USER, TENANT_A, false); + + // -- ASSERT -- + // This is the fail-open window, asserted deliberately: the cached clearance still contains + // tlp-red, so is_marking_set_allowed keeps returning true for RED rows. Nothing downstream + // can detect this — the predicate never consults marking_definitions. Only eviction fixes it. + assertThat(stale.toGuc()).isEqualTo("tlp-green,tlp-red"); + + // -- ACT -- + clearanceCache.evict(USER, TENANT_A); + + // -- ASSERT -- + assertThat(clearanceCache.findClearance(USER, TENANT_A, false).toGuc()) + .isEqualTo("tlp-green"); + } + + @Test + @DisplayName("given evict, should re-read only the affected user and tenant") + void given_evict_should_beScopedToOneEntry() { + // -- ARRANGE -- + givenGrants(TENANT_A, List.of(TLP_GREEN), List.of("tlp-green")); + givenGrants(TENANT_B, List.of(TLP_GREEN), List.of("tlp-green")); + clearanceCache.findClearance(USER, TENANT_A, false); + clearanceCache.findClearance(USER, TENANT_B, false); + + // -- ACT -- + clearanceCache.evict(USER, TENANT_A); + clearanceCache.findClearance(USER, TENANT_A, false); + clearanceCache.findClearance(USER, TENANT_B, false); + + // -- ASSERT -- + verify(jdbcTemplate, times(2)).query(anyString(), any(RowMapper.class), eq(TENANT_A)); + verify(jdbcTemplate, times(1)).query(anyString(), any(RowMapper.class), eq(TENANT_B)); + } + + @Test + @DisplayName("given evict, should drop the bypass variant too, not just the one named") + void given_evict_should_dropBothBypassVariants() { + // -- ARRANGE -- + // Both variants warm. If evict dropped only one, the surviving entry would be the WIDER of + // the two (bypass), which is precisely the fail-open case eviction exists to prevent. + givenGrants(TENANT_A, List.of(TLP_GREEN, TLP_RED), List.of("tlp-green")); + clearanceCache.findClearance(USER, TENANT_A, false); + clearanceCache.findClearance(USER, TENANT_A, true); + + // -- ACT -- + clearanceCache.evict(USER, TENANT_A); + clearanceCache.findClearance(USER, TENANT_A, false); + clearanceCache.findClearance(USER, TENANT_A, true); + + // -- ASSERT -- + // Two reads before, two after: neither variant survived. + verify(jdbcTemplate, times(4)).query(anyString(), any(RowMapper.class), eq(TENANT_A)); + } + + @Test + @DisplayName("given a membership change, should drop the user's clearance in EVERY tenant") + void given_evictForUser_should_reachEveryTenant() { + // -- ARRANGE -- + // A Group is dual-scope: a platform group grants markings across tenants, and users_groups + // carries no tenant. So dropping a user from one reduces their clearance everywhere at once. + // evict(user, ONE tenant) would leave the others stale, and stale-larger fails open. + givenGrants(TENANT_A, List.of(TLP_GREEN), List.of("tlp-green")); + givenGrants(TENANT_B, List.of(TLP_RED), List.of("tlp-red")); + when(tenantMembershipCacheManager.findTenantIdsByUserId(USER)) + .thenReturn(List.of(TENANT_A, TENANT_B)); + clearanceCache.findClearance(USER, TENANT_A, false); + clearanceCache.findClearance(USER, TENANT_B, false); + + // -- ACT -- + clearanceCache.evictForUser(USER); + clearanceCache.findClearance(USER, TENANT_A, false); + clearanceCache.findClearance(USER, TENANT_B, false); + + // -- ASSERT -- + verify(jdbcTemplate, times(2)).query(anyString(), any(RowMapper.class), eq(TENANT_A)); + verify(jdbcTemplate, times(2)).query(anyString(), any(RowMapper.class), eq(TENANT_B)); + } + + @Test + @DisplayName("given a membership change, should drop the bypass variant in every tenant too") + void given_evictForUser_should_dropBothVariantsPerTenant() { + // -- ARRANGE -- + givenGrants(TENANT_A, List.of(TLP_GREEN), List.of("tlp-green")); + when(tenantMembershipCacheManager.findTenantIdsByUserId(USER)).thenReturn(List.of(TENANT_A)); + clearanceCache.findClearance(USER, TENANT_A, false); + clearanceCache.findClearance(USER, TENANT_A, true); + + // -- ACT -- + clearanceCache.evictForUser(USER); + clearanceCache.findClearance(USER, TENANT_A, false); + clearanceCache.findClearance(USER, TENANT_A, true); + + // -- ASSERT -- + verify(jdbcTemplate, times(4)).query(anyString(), any(RowMapper.class), eq(TENANT_A)); + } + + @Test + @DisplayName("given a tenant the user cannot reach, should not fail resolving it") + void given_evictForUser_should_tolerateAnEmptyTenantList() { + // -- ARRANGE -- + // A user with no tenants is a normal state, not an error: nothing to evict. + when(tenantMembershipCacheManager.findTenantIdsByUserId(USER)).thenReturn(List.of()); + + // -- ACT & ASSERT -- + assertDoesNotThrow(() -> clearanceCache.evictForUser(USER)); + } + + @Test + @DisplayName("given evictForUsers, should reach every user named") + void given_evictForUsers_should_reachEveryUser() { + // -- ARRANGE -- + // The group paths evict a whole membership list at once. + when(tenantMembershipCacheManager.findTenantIdsByUserId(anyString())) + .thenReturn(List.of(TENANT_A)); + + // -- ACT -- + clearanceCache.evictForUsers(List.of(USER, "user-2", "user-3")); + + // -- ASSERT -- + verify(tenantMembershipCacheManager).findTenantIdsByUserId(USER); + verify(tenantMembershipCacheManager).findTenantIdsByUserId("user-2"); + verify(tenantMembershipCacheManager).findTenantIdsByUserId("user-3"); + } + + @Test + @DisplayName("given evictAll, should drop every entry") + void given_evictAll_should_dropEverything() { + // -- ARRANGE -- + // The blunt eviction covers the changes that reduce an unbounded set of users at once: + // a marking unassigned from a group, a group deleted, a definition archived. + givenGrants(TENANT_A, List.of(TLP_GREEN), List.of("tlp-green")); + givenGrants(TENANT_B, List.of(TLP_GREEN), List.of("tlp-green")); + clearanceCache.findClearance(USER, TENANT_A, false); + clearanceCache.findClearance(USER, TENANT_B, false); + + // -- ACT -- + clearanceCache.evictAll(); + clearanceCache.findClearance(USER, TENANT_A, false); + clearanceCache.findClearance(USER, TENANT_B, false); + + // -- ASSERT -- + verify(jdbcTemplate, times(2)).query(anyString(), any(RowMapper.class), eq(TENANT_A)); + verify(jdbcTemplate, times(2)).query(anyString(), any(RowMapper.class), eq(TENANT_B)); + } + } + + @Nested + @DisplayName("registration") + class Registration { + + @Test + @DisplayName("the cache must be registered, or @Cacheable silently does nothing") + void cacheIsRegistered() { + assertThat(cacheManager.getCache("markingClearance")).isNotNull(); + } + } +} diff --git a/openaev-api/src/test/java/io/openaev/config/cache/MarkingClearanceCacheManagerTest.java b/openaev-api/src/test/java/io/openaev/config/cache/MarkingClearanceCacheManagerTest.java new file mode 100644 index 00000000000..101f7e1f9d4 --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/config/cache/MarkingClearanceCacheManagerTest.java @@ -0,0 +1,159 @@ +package io.openaev.config.cache; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +import io.openaev.annotation.AllowRawJdbc; +import io.openaev.config.MarkingScopeResolver; +import io.openaev.config.MarkingScopeResolver.MarkingRef; +import io.openaev.context.MarkingCtx; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.cache.CacheManager; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +/** + * Pins the raw-JDBC exemption of {@link MarkingClearanceCacheManager}. + * + *

{@code marking_definitions} is a tenant-active table, so raw JDBC bypasses the statement + * inspector that would otherwise scope it. That is sanctioned here only because the read bootstraps + * the scope (it runs during argument resolution, before any transaction exists) — the same + * chicken-and-egg the {@code AutonomousRunTenantLocator} exemption rests on. What replaces the + * inspector is an explicit {@code tenant_id} bind in both statements, so this test pins that bind: + * dropping it would silently resolve a clearance from every tenant's markings at once. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("marking clearance resolution stays a pinned, tenant-bound scope-bootstrap read") +class MarkingClearanceCacheManagerTest { + + private static final String USER = "user-1"; + private static final String TENANT = "tenant-1"; + + @Mock private JdbcTemplate jdbcTemplate; + @Mock private CacheManager cacheManager; + @Mock private TenantMembershipCacheManager tenantMembershipCacheManager; + + private final MarkingScopeResolver resolver = new MarkingScopeResolver(); + + private MarkingClearanceCacheManager manager() { + return new MarkingClearanceCacheManager( + jdbcTemplate, resolver, cacheManager, tenantMembershipCacheManager); + } + + @Nested + @DisplayName("the SQL exemption") + class SqlExemption { + + @Test + @DisplayName("both statements must filter on tenant_id, the inspector's replacement") + void bothStatementsAreTenantBound() { + // -- ACT & ASSERT -- + assertTrue( + MarkingClearanceCacheManager.TENANT_MARKINGS_SQL.contains("where tenant_id = ?"), + "the definitions read must be tenant-bound"); + assertTrue( + MarkingClearanceCacheManager.GRANTED_MARKING_IDS_SQL.contains("md.tenant_id = ?"), + "the grants read must be tenant-bound through the definition side"); + } + + @Test + @DisplayName("both statements must be read-only SELECTs") + void bothStatementsAreReadOnly() { + // -- ACT & ASSERT -- + // A write here would bypass the inspector's write predicate as well as its read one. + for (String sql : + List.of( + MarkingClearanceCacheManager.TENANT_MARKINGS_SQL, + MarkingClearanceCacheManager.GRANTED_MARKING_IDS_SQL)) { + assertTrue(sql.trim().toLowerCase().startsWith("select"), sql); + assertFalse(sql.toLowerCase().matches(".*\\b(insert|update|delete|merge)\\b.*"), sql); + } + } + + @Test + @DisplayName("the exemption is declared, with a reason") + void exemptionIsDeclared() { + // -- ACT -- + AllowRawJdbc annotation = + MarkingClearanceCacheManager.class.getAnnotation(AllowRawJdbc.class); + + // -- ASSERT -- + assertNotNull(annotation, "raw JDBC on a tenant-active table must opt out explicitly"); + assertThat(annotation.reason()).contains("tenant_id"); + } + } + + @Nested + @DisplayName("resolution") + class Resolution { + + @Test + @DisplayName("given a granted marking, should pass the tenant to both queries") + void given_aGrant_should_bindTheTenantEverywhere() { + // -- ARRANGE -- + when(jdbcTemplate.query( + eq(MarkingClearanceCacheManager.TENANT_MARKINGS_SQL), + any(RowMapper.class), + eq(TENANT))) + .thenReturn(List.of(new MarkingRef("tlp-green", "TLP", 20))); + when(jdbcTemplate.queryForList( + eq(MarkingClearanceCacheManager.GRANTED_MARKING_IDS_SQL), + eq(String.class), + eq(USER), + eq(TENANT))) + .thenReturn(List.of("tlp-green")); + + // -- ACT -- + MarkingCtx clearance = manager().findClearance(USER, TENANT, false); + + // -- ASSERT -- + assertEquals("tlp-green", clearance.toGuc()); + verify(jdbcTemplate).queryForList(anyString(), eq(String.class), eq(USER), eq(TENANT)); + } + + @Test + @DisplayName("given a bypassing caller, should skip the grants query entirely") + void given_bypass_should_notQueryGrants() { + // -- ARRANGE -- + when(jdbcTemplate.query( + eq(MarkingClearanceCacheManager.TENANT_MARKINGS_SQL), + any(RowMapper.class), + eq(TENANT))) + .thenReturn(List.of(new MarkingRef("tlp-red", "TLP", 50))); + + // -- ACT -- + MarkingCtx clearance = manager().findClearance(USER, TENANT, true); + + // -- ASSERT -- + // The grants cannot change a bypassing caller's answer, so the round trip is waste. + assertEquals("tlp-red", clearance.toGuc()); + verify(jdbcTemplate, never()) + .queryForList(anyString(), eq(String.class), any(Object[].class)); + } + + @Test + @DisplayName("given a user with no grants, should resolve to none() and not fail") + void given_noGrants_should_resolveToNone() { + // -- ARRANGE -- + when(jdbcTemplate.query( + eq(MarkingClearanceCacheManager.TENANT_MARKINGS_SQL), + any(RowMapper.class), + eq(TENANT))) + .thenReturn(List.of(new MarkingRef("tlp-green", "TLP", 20))); + when(jdbcTemplate.queryForList(anyString(), eq(String.class), eq(USER), eq(TENANT))) + .thenReturn(List.of()); + + // -- ACT & ASSERT -- + assertEquals(MarkingCtx.none(), manager().findClearance(USER, TENANT, false)); + } + } +} diff --git a/openaev-api/src/test/java/io/openaev/context/MarkingCtxTest.java b/openaev-api/src/test/java/io/openaev/context/MarkingCtxTest.java new file mode 100644 index 00000000000..488d47b6c02 --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/context/MarkingCtxTest.java @@ -0,0 +1,104 @@ +package io.openaev.context; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("MarkingCtx") +class MarkingCtxTest { + + @Nested + @DisplayName("none()") + class NoneTests { + + @Test + @DisplayName("serializes to the empty string") + void noneSerializesToEmptyString() { + assertEquals("", MarkingCtx.none().toGuc()); + } + + @Test + @DisplayName("two instances are equal") + void noneEquality() { + assertEquals(MarkingCtx.none(), MarkingCtx.none()); + } + + @Test + @DisplayName("an empty collection resolves to none() rather than an empty Restricted") + void emptyCollectionCollapsesToNone() { + // -- ACT & ASSERT -- + // The empty set is a legitimate clearance (a user who holds nothing), unlike an empty tenant + // scope which is an error. Restricted rejects empty, so forMarkings must fold it here. + assertEquals(MarkingCtx.none(), MarkingCtx.forMarkings(Set.of())); + } + } + + @Nested + @DisplayName("forMarkings()") + class RestrictedTests { + + @Test + @DisplayName("serializes as a comma-separated list, preserving order") + void restrictedSerializes() { + assertEquals("m1,m2,m3", MarkingCtx.forMarkings(List.of("m1", "m2", "m3")).toGuc()); + } + + @Test + @DisplayName("a single marking serializes without a separator") + void singleMarkingSerializes() { + assertEquals("m1", MarkingCtx.forMarkings(List.of("m1")).toGuc()); + } + + @Test + @DisplayName("rejects a blank marking id") + void rejectsBlankId() { + assertThrows(IllegalArgumentException.class, () -> MarkingCtx.forMarkings(List.of(" "))); + } + + @Test + @DisplayName("rejects an id containing the separator, which would forge two markings") + void rejectsIdContainingSeparator() { + // -- ACT & ASSERT -- + // "a,b" would deserialize on the SQL side as two ids, silently widening the clearance. + assertThrows(IllegalArgumentException.class, () -> MarkingCtx.forMarkings(List.of("a,b"))); + } + + @Test + @DisplayName("is immutable: mutating the source collection does not change the clearance") + void copiesTheSourceCollection() { + // -- ARRANGE -- + List source = new java.util.ArrayList<>(List.of("m1")); + MarkingCtx clearance = MarkingCtx.forMarkings(source); + + // -- ACT -- + source.add("m2"); + + // -- ASSERT -- + assertEquals("m1", clearance.toGuc()); + } + } + + @Nested + @DisplayName("all()") + class AllTests { + + @Test + @DisplayName("refuses to serialize: an intention must be resolved before it reaches the GUC") + void allCannotSerialize() { + // -- ACT & ASSERT -- + // Mirrors TxCtx.AllTenants: no wildcard ever reaches the scope channel, so a bug in the + // resolution path fails loudly here instead of quietly granting every marking. + assertThrows(IllegalStateException.class, () -> MarkingCtx.all().toGuc()); + } + + @Test + @DisplayName("two instances are equal") + void allEquality() { + assertEquals(MarkingCtx.all(), MarkingCtx.all()); + } + } +} diff --git a/openaev-api/src/test/java/io/openaev/context/TenantScopedTransactionMarkingScopeTest.java b/openaev-api/src/test/java/io/openaev/context/TenantScopedTransactionMarkingScopeTest.java new file mode 100644 index 00000000000..7aa1cd5de05 --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/context/TenantScopedTransactionMarkingScopeTest.java @@ -0,0 +1,208 @@ +package io.openaev.context; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.openaev.IntegrationTest; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import javax.sql.DataSource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * The marking half of the background transaction primitive: a background transaction runs at + * system clearance — every marking of the tenants in scope. + * + *

Note the asymmetry with the tenant dimension, because it is the point of this test rather than + * an accident. The primitive narrows a job to its tenants, since a tenant is a real boundary + * it must respect. It widens it to all markings, since a marking is a boundary between + * users and a scheduler is not a user. Concretely: activating a table on marking must be a + * no-op for background work — an inject targeting a TLP:RED asset still executes. + * + *

This also pins the allowlist entry in {@code + * TenantActiveTableAccessArchTest#marking_definitions_repository_access_is_reviewed}: the + * primitive's read of {@code marking_definitions} is properly tenant-scoped, and this test is what + * makes that claim checkable rather than a comment. + * + *

Deliberately NOT {@code @Transactional}: the primitive opens its own transactions. + */ +@DisplayName("TenantScopedTransaction: the marking scope a background transaction runs at") +class TenantScopedTransactionMarkingScopeTest extends IntegrationTest { + + @Autowired private TenantScopedTransaction tenantTx; + @Autowired private PlatformTransactionManager transactionManager; + @Autowired private DataSource dataSource; + + private JdbcTemplate jdbc; + private String tenantA; + private String tenantB; + + @BeforeEach + void seedTwoTenantsWithDifferentScales() { + jdbc = new JdbcTemplate(dataSource); + tenantA = seedTenant("marking-scope-a-" + UUID.randomUUID()); + tenantB = seedTenant("marking-scope-b-" + UUID.randomUUID()); + seedMarking(tenantA, "TLP", "TLP:GREEN", 20); + seedMarking(tenantA, "TLP", "TLP:RED", 50); + seedMarking(tenantB, "PAP", "PAP:AMBER", 30); + } + + @AfterEach + void cleanup() { + jdbc.update("DELETE FROM marking_definitions WHERE tenant_id IN (?, ?)", tenantA, tenantB); + jdbc.update("DELETE FROM tenants WHERE tenant_id IN (?, ?)", tenantA, tenantB); + } + + @Nested + @DisplayName("system clearance") + class SystemClearance { + + @Test + @DisplayName("given a tenant scope, should carry every marking of that tenant") + void given_tenantScope_should_carryEveryMarkingOfTheTenant() { + // -- ACT -- + Set inside = tenantTx.execute(TxCtx.forTenant(tenantA), () -> currentMarkingScope()); + + // -- ASSERT -- + // Not "the highest", not "none": all of them. A job is not a user, so it holds the whole + // scale — which is what makes activating a marked table invisible to background work. + assertEquals(markingIdsOf(tenantA), inside); + } + + @Test + @DisplayName("given a tenant scope, should NOT carry another tenant's markings") + void given_tenantScope_should_notCarryAnotherTenantsMarkings() { + // -- ACT -- + Set inside = tenantTx.execute(TxCtx.forTenant(tenantA), () -> currentMarkingScope()); + + // -- ASSERT -- + // The widening is per-tenant. Marking is a boundary between users; tenant is still a wall. + assertTrue( + Set.copyOf(markingIdsOf(tenantB)).stream().noneMatch(inside::contains), + "tenant B's markings must not leak into tenant A's system clearance: " + inside); + } + + @Test + @DisplayName("given a multi-tenant scope, should carry the union of both scales") + void given_multiTenantScope_should_carryTheUnion() { + // -- ACT -- + Set inside = + tenantTx.execute( + TxCtx.forTenants(List.of(tenantA, tenantB)), () -> currentMarkingScope()); + + // -- ASSERT -- + assertTrue( + inside.containsAll(markingIdsOf(tenantA)), "tenant A's scale is missing: " + inside); + assertTrue( + inside.containsAll(markingIdsOf(tenantB)), "tenant B's scale is missing: " + inside); + } + + @Test + @DisplayName("given a tenant with no markings, should carry an empty scope, not fail") + void given_tenantWithoutMarkings_should_carryEmptyScope() { + // -- ARRANGE -- + String bare = seedTenant("marking-scope-bare-" + UUID.randomUUID()); + try { + // -- ACT -- + String inside = + tenantTx.execute(TxCtx.forTenant(bare), () -> currentSetting("app.current_markings")); + + // -- ASSERT -- + // Correct rather than degraded: with nothing marked, every row is unmarked and visible. + assertEquals("", inside); + } finally { + jdbc.update("DELETE FROM tenants WHERE tenant_id = ?", bare); + } + } + } + + @Nested + @DisplayName("the scope channel") + class ScopeChannel { + + @Test + @DisplayName("given the transaction ended, should leave no marking scope behind") + void given_transactionEnded_should_leaveNothingBehind() { + // -- ARRANGE -- + tenantTx.execute(TxCtx.forTenant(tenantA), () -> currentMarkingScope()); + + // -- ACT -- + String after = rawTransaction().execute(status -> currentSetting("app.current_markings")); + + // -- ASSERT -- + // set_config(..., true) is transaction-local: nothing survives onto a reused connection. + assertEquals("", after); + } + + @Test + @DisplayName("given a raw transaction, should carry no marking scope at all") + void given_rawTransaction_should_carryNoMarkingScope() { + // -- ACT -- + String scope = rawTransaction().execute(status -> currentSetting("app.current_markings")); + + // -- ASSERT -- + // The primitive is the only door. Bypassing it yields an empty clearance — which for marking + // means unmarked rows only, a narrowing rather than a leak. + assertEquals("", scope); + } + } + + // -- HELPERS -- + + private Set currentMarkingScope() { + String raw = currentSetting("app.current_markings"); + return raw.isEmpty() ? Set.of() : Set.copyOf(Arrays.asList(raw.split(","))); + } + + private String currentSetting(String key) { + return jdbc.queryForObject( + "SELECT coalesce(current_setting('" + key + "', true), '')", String.class); + } + + private Set markingIdsOf(String tenantId) { + return Set.copyOf( + jdbc.queryForList( + "SELECT marking_id FROM marking_definitions WHERE tenant_id = ?", + String.class, + tenantId)); + } + + private TransactionTemplate rawTransaction() { + TransactionTemplate template = new TransactionTemplate(transactionManager); + template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + return template; + } + + private String seedTenant(String name) { + String id = UUID.randomUUID().toString(); + jdbc.update( + "INSERT INTO tenants (tenant_id, tenant_name, tenant_created_at, tenant_updated_at)" + + " VALUES (?, ?, now(), now())", + id, + name); + return id; + } + + private void seedMarking(String tenantId, String type, String name, int order) { + jdbc.update( + "INSERT INTO marking_definitions (marking_id, marking_type, marking_name, marking_order," + + " marking_created_at, marking_updated_at, tenant_id)" + + " VALUES (?, ?, ?, ?, now(), now(), ?)", + UUID.randomUUID().toString(), + type, + name, + order, + tenantId); + } +} diff --git a/openaev-api/src/test/java/io/openaev/utils/fixtures/MarkingDefinitionFixture.java b/openaev-api/src/test/java/io/openaev/utils/fixtures/MarkingDefinitionFixture.java new file mode 100644 index 00000000000..be78d811d2e --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/utils/fixtures/MarkingDefinitionFixture.java @@ -0,0 +1,81 @@ +package io.openaev.utils.fixtures; + +import io.openaev.api.markings.form.MarkingDefinitionInput; +import io.openaev.database.model.MarkingDefinition; +import java.util.UUID; + +/** + * Test data for {@link MarkingDefinition}. + * + *

Every generated name is unique. This is not cosmetic: the migration seeds nine default + * markings (TLP:CLEAR..TLP:RED, PAP:CLEAR..PAP:RED) for EVERY tenant, so a fixture reusing one of + * those names would collide with seeded ground truth. Unique names also keep assertions robust to + * the seed — filter by a fixture-specific name instead of counting rows. + */ +public class MarkingDefinitionFixture { + + public static final String DEFAULT_COLOR = "#c62828"; + public static final String ALTERNATE_COLOR = "#2e7d32"; + public static final String INVALID_COLOR = "not-a-colour"; + + /** Above the seeded defaults' 10..50 band, so fixtures never tie with a seeded order. */ + public static final int DEFAULT_ORDER = 60; + + private MarkingDefinitionFixture() {} + + /** A name that cannot collide with the nine per-tenant defaults. */ + public static String uniqueName() { + return "MARKING:" + UUID.randomUUID().toString().substring(0, 8).toUpperCase(); + } + + /** A shared, unique token usable as a textSearch needle across a group of fixtures. */ + public static String uniqueSearchToken() { + return "SEARCHTOKEN" + UUID.randomUUID().toString().substring(0, 8).toUpperCase(); + } + + // -- ENTITIES (tenant attribution is the composer's job: v2 stamps no tenant automatically) -- + + public static MarkingDefinition createDefaultMarkingDefinition() { + return createMarkingDefinition( + MarkingDefinition.TYPE_TLP, uniqueName(), DEFAULT_ORDER, DEFAULT_COLOR); + } + + public static MarkingDefinition createMarkingDefinitionWithName(String name) { + return createMarkingDefinition(MarkingDefinition.TYPE_TLP, name, DEFAULT_ORDER, DEFAULT_COLOR); + } + + public static MarkingDefinition createMarkingDefinition( + String type, String name, int order, String color) { + MarkingDefinition marking = new MarkingDefinition(); + marking.setType(type); + marking.setName(name); + marking.setOrder(order); + marking.setColor(color); + return marking; + } + + // -- INPUTS -- + + public static MarkingDefinitionInput createDefaultInput() { + return createInput(MarkingDefinition.TYPE_TLP, uniqueName(), DEFAULT_ORDER, DEFAULT_COLOR); + } + + public static MarkingDefinitionInput createInputWithName(String name) { + return createInput(MarkingDefinition.TYPE_TLP, name, DEFAULT_ORDER, DEFAULT_COLOR); + } + + public static MarkingDefinitionInput createInputWithColor(String color) { + return createInput(MarkingDefinition.TYPE_TLP, uniqueName(), DEFAULT_ORDER, color); + } + + public static MarkingDefinitionInput createInput( + String type, String name, int order, String color) { + return new MarkingDefinitionInput(type, name, order, color); + } + + /** Round-trips a persisted entity into an update payload. */ + public static MarkingDefinitionInput toInput(MarkingDefinition marking) { + return new MarkingDefinitionInput( + marking.getType(), marking.getName(), marking.getOrder(), marking.getColor()); + } +} diff --git a/openaev-api/src/test/java/io/openaev/utils/fixtures/composers/MarkingDefinitionComposer.java b/openaev-api/src/test/java/io/openaev/utils/fixtures/composers/MarkingDefinitionComposer.java new file mode 100644 index 00000000000..f7f0561a6e1 --- /dev/null +++ b/openaev-api/src/test/java/io/openaev/utils/fixtures/composers/MarkingDefinitionComposer.java @@ -0,0 +1,88 @@ +package io.openaev.utils.fixtures.composers; + +import io.openaev.database.model.MarkingDefinition; +import io.openaev.database.model.Tenant; +import io.openaev.database.repository.MarkingDefinitionRepository; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * {@code marking_definitions} is tenant-isolated on v2, which means there is no {@code + * TenantBaseListener} to stamp {@code tenant_id} on persist — yet the column is {@code NOT NULL}. A + * caller MUST therefore pin a tenant via {@link Composer#withTenantId} (or {@link + * Composer#withTenant}) before {@link Composer#persist()}, otherwise the insert fails on the FK. + */ +@Component +public class MarkingDefinitionComposer extends ComposerBase { + + @Autowired private MarkingDefinitionRepository markingDefinitionRepository; + @PersistenceContext private EntityManager entityManager; + + public class Composer extends InnerComposerBase { + + private final MarkingDefinition markingDefinition; + + public Composer(MarkingDefinition markingDefinition) { + this.markingDefinition = markingDefinition; + } + + public Composer withId(String id) { + this.markingDefinition.setId(id); + return this; + } + + public Composer withTenant(Tenant tenant) { + this.markingDefinition.setTenant(tenant); + return this; + } + + /** Reference-only load: no select is issued for a tenant we only need the FK of. */ + public Composer withTenantId(String tenantId) { + return withTenant(entityManager.getReference(Tenant.class, tenantId)); + } + + public Composer withName(String name) { + this.markingDefinition.setName(name); + return this; + } + + public Composer withType(String type) { + this.markingDefinition.setType(type); + return this; + } + + public Composer withOrder(Integer order) { + this.markingDefinition.setOrder(order); + return this; + } + + public Composer withColor(String color) { + this.markingDefinition.setColor(color); + return this; + } + + @Override + public Composer persist() { + markingDefinitionRepository.save(this.markingDefinition); + return this; + } + + @Override + public Composer delete() { + markingDefinitionRepository.delete(this.markingDefinition); + return this; + } + + @Override + public MarkingDefinition get() { + return this.markingDefinition; + } + } + + public Composer forMarkingDefinition(MarkingDefinition markingDefinition) { + generatedItems.add(markingDefinition); + return new Composer(markingDefinition); + } +} diff --git a/openaev-api/src/test/resources/application.properties b/openaev-api/src/test/resources/application.properties index 1edf773c60e..67366fcbce8 100644 --- a/openaev-api/src/test/resources/application.properties +++ b/openaev-api/src/test/resources/application.properties @@ -227,3 +227,8 @@ spring.datasource.hikari.maximum-pool-size=5 spring.datasource.hikari.minimum-idle=1 openaev.enabled-dev-features= + +# Marking-active tables. This file SHADOWS src/main/resources/application.properties entirely +# (Spring Boot loads the first application.properties on the classpath, it does not merge them), so +# a table activated in main is NOT active in tests unless it is repeated here. Keep the two in sync. +openaev.marking.active-tables=assets diff --git a/openaev-front/src/actions/markings/marking-definition-actions.ts b/openaev-front/src/actions/markings/marking-definition-actions.ts new file mode 100644 index 00000000000..9e639af78e1 --- /dev/null +++ b/openaev-front/src/actions/markings/marking-definition-actions.ts @@ -0,0 +1,17 @@ +import { simplePostCall } from '../../utils/Action'; +import type { SearchPaginationInput } from '../../utils/api-types'; + +const MARKING_DEFINITION_URI = '/api/marking-definitions'; + +// -- SEARCH -- + +/** + * Resolves marking ids carried by a row (`asset_markings`) to their definitions. + * + * Only the search call is exposed: managing definitions is done through the API in this PoC, so + * there is no create/update/delete UI to back the rest of the CRUD surface. + */ +// eslint-disable-next-line import/prefer-default-export +export const searchMarkingDefinitions = (searchPaginationInput: SearchPaginationInput) => { + return simplePostCall(`${MARKING_DEFINITION_URI}/search`, searchPaginationInput); +}; diff --git a/openaev-front/src/admin/components/assets/endpoints/Endpoints.tsx b/openaev-front/src/admin/components/assets/endpoints/Endpoints.tsx index 4ed7b095bda..b35d6202009 100644 --- a/openaev-front/src/admin/components/assets/endpoints/Endpoints.tsx +++ b/openaev-front/src/admin/components/assets/endpoints/Endpoints.tsx @@ -27,6 +27,7 @@ import useBodyItemsStyles from '../../../../components/common/queryable/style/st import { useQueryableWithLocalStorage } from '../../../../components/common/queryable/useQueryableWithLocalStorage'; import { useFormatter } from '../../../../components/i18n'; import ItemCriticality from '../../../../components/ItemCriticality'; +import ItemMarkings from '../../../../components/ItemMarkings'; import ItemTags from '../../../../components/ItemTags'; import PaginatedListLoader from '../../../../components/PaginatedListLoader'; import { ASSET_BASE_URL } from '../../../../constants/BaseUrls'; @@ -34,6 +35,7 @@ import { type EndpointOutput, type SearchPaginationInput } from '../../../../uti import { useAppDispatch } from '../../../../utils/hooks'; import useDataLoader from '../../../../utils/hooks/useDataLoader'; import useEntityToggle from '../../../../utils/hooks/useEntityToggle'; +import useMarkingDefinitions from '../../../../utils/hooks/useMarkingDefinitions'; import { AbilityContext, Can } from '../../../../utils/permissions/permissionsContext'; import { ACTIONS, SUBJECTS } from '../../../../utils/permissions/types'; import EndpointListItemFragments from '../../common/endpoints/EndpointListItemFragments'; @@ -60,7 +62,8 @@ const inlineStyles: Record = { endpoint_agents_executor: { width: '12%' }, asset_criticality: { width: '9%' }, asset_posture: { width: '10%' }, - asset_tags: { width: '19%' }, + asset_tags: { width: '10%' }, + asset_markings: { width: '9%' }, }; const Endpoints = () => { @@ -69,6 +72,8 @@ const Endpoints = () => { const bodyItemsStyles = useBodyItemsStyles(); const { t } = useFormatter(); const dispatch = useAppDispatch(); + // Resolved once for the whole page; the Markings column maps ids per row. + const markingDefinitions = useMarkingDefinitions(); // Load the executors once for the whole page; the per-row Executors column // reads them from the store (previously each row fetched them, firing @@ -214,6 +219,19 @@ const Endpoints = () => { isSortable: false, value: (endpoint: EndpointOutput) => , }, + { + field: 'asset_markings', + label: 'Markings', + // Not sortable: markings are stored as a text[] on the row, not a joinable column. + isSortable: false, + value: (endpoint: EndpointOutput) => ( + + ), + }, ]; return ( diff --git a/openaev-front/src/components/ItemMarkings.tsx b/openaev-front/src/components/ItemMarkings.tsx new file mode 100644 index 00000000000..536ee4afb89 --- /dev/null +++ b/openaev-front/src/components/ItemMarkings.tsx @@ -0,0 +1,79 @@ +import { Chip, Tooltip } from '@mui/material'; +import { useMemo } from 'react'; + +import { type MarkingDefinitionOutput } from '../utils/api-types'; +import { hexToRGB } from '../utils/Colors'; +import { truncate } from '../utils/String'; + +interface Props { + /** Marking definition ids carried by the entity, as returned in `asset_markings`. */ + markingIds?: string[]; + /** + * Resolved definitions, keyed by id. Passed in rather than fetched here so a 50-row list issues + * one request for the whole page instead of one per row - see `useMarkingDefinitions`. + */ + definitions: Record; + variant?: 'list'; + limit?: number; +} + +const ItemMarkings = ({ markingIds, definitions, variant, limit = 2 }: Props) => { + const chipSx = { + height: variant === 'list' ? 20 : 25, + fontSize: 12, + margin: 0, + borderRadius: 1, + }; + + // An id with no matching definition is dropped rather than rendered raw: marking ids are stored + // inline as text[] with no foreign key, so a deleted definition can leave a dangling id behind. + const resolved = useMemo( + () => (markingIds ?? []) + .map(id => definitions[id]) + .filter((marking): marking is MarkingDefinitionOutput => !!marking) + .sort((a, b) => a.marking_order - b.marking_order), + [markingIds, definitions], + ); + + // Sliced directly rather than through the String helpers used by ItemTags: those accept + // nullable inputs and so return nullable results, which `resolved` never is. + const visible = resolved.slice(0, limit); + const remaining = resolved.length - visible.length; + const tooltipLabel = resolved.slice(limit).map(marking => marking.marking_name).join(', '); + + if (resolved.length === 0) { + return -; + } + + return ( +

+ {visible.map((marking: MarkingDefinitionOutput) => ( + + + + ))} + {remaining > 0 && ( + + + + )} +
+ ); +}; + +export default ItemMarkings; diff --git a/openaev-front/src/utils/api-types.d.ts b/openaev-front/src/utils/api-types.d.ts index 6a45a6f73db..a1bedf5d721 100644 --- a/openaev-front/src/utils/api-types.d.ts +++ b/openaev-front/src/utils/api-types.d.ts @@ -554,6 +554,7 @@ export interface Asset { asset_ips?: string[]; asset_linked_person?: string; asset_mac_addresses?: string[]; + asset_markings?: string[]; asset_metadata?: Record; /** @minLength 1 */ asset_name: string; @@ -937,6 +938,10 @@ export interface AssetSnapshotOutput { asset_snapshot_name?: string; } +export interface AssetUpdateMarkingsInput { + asset_markings: string[]; +} + export interface AtomicInjectorContractOutput { convertedContent?: object; /** @minLength 1 */ @@ -3790,6 +3795,7 @@ export interface Endpoint { asset_ips?: string[]; asset_linked_person?: string; asset_mac_addresses?: string[]; + asset_markings?: string[]; asset_metadata?: Record; /** @minLength 1 */ asset_name: string; @@ -4079,6 +4085,11 @@ export interface EndpointOutput { asset_internet_facing?: boolean; /** Linked person (user id) for identity assets */ asset_linked_person?: string; + /** + * Marking definition ids carried by the asset + * @uniqueItems true + */ + asset_markings?: string[]; /** * Asset name * @minLength 1 @@ -5880,6 +5891,7 @@ export interface Group { group_grants?: Grant[]; /** @minLength 1 */ group_id: string; + group_markings?: string[]; /** @minLength 1 */ group_name: string; group_roles?: string[]; @@ -5899,6 +5911,10 @@ export interface GroupGrantInput { | "UNKNOWN"; } +export interface GroupUpdateMarkingsInput { + group_markings: string[]; +} + export interface GroupUpdateRolesInput { /** List of role ids associated with the group */ group_roles?: string[]; @@ -7601,6 +7617,41 @@ export interface MapperConditionOutput { condition_value?: string; } +export interface MarkingDefinitionInput { + /** + * Display colour, as a hex code + * @pattern ^#[0-9a-fA-F]{6}$ + */ + marking_color?: string; + /** + * Name of the marking, unique within the tenant, e.g. TLP:RED + * @minLength 1 + */ + marking_name: string; + /** + * Rank within the scale — higher is more restrictive. Holding a level implies holding every lower level of the same scale. + * @format int32 + */ + marking_order: number; + /** + * Classification scale, e.g. TLP or PAP + * @minLength 1 + */ + marking_type: string; +} + +export interface MarkingDefinitionOutput { + marking_color?: string; + /** @minLength 1 */ + marking_id: string; + /** @minLength 1 */ + marking_name: string; + /** @format int32 */ + marking_order: number; + /** @minLength 1 */ + marking_type: string; +} + export interface MissingImportedAction { name?: string; type?: string; @@ -7822,6 +7873,7 @@ export interface NotificationTriggerInput { | "JOB" | "TAG" | "TAG_RULE" + | "MARKING_DEFINITION" | "KILL_CHAIN_PHASE" | "ATTACK_PATTERN" | "ASSET_GROUP" @@ -7926,6 +7978,7 @@ export interface NotificationTriggerOutput { | "JOB" | "TAG" | "TAG_RULE" + | "MARKING_DEFINITION" | "KILL_CHAIN_PHASE" | "ATTACK_PATTERN" | "ASSET_GROUP" @@ -8501,6 +8554,25 @@ export interface PageLessonsTemplate { totalPages?: number; } +export interface PageMarkingDefinitionOutput { + content?: MarkingDefinitionOutput[]; + empty?: boolean; + first?: boolean; + last?: boolean; + /** @format int32 */ + number?: number; + /** @format int32 */ + numberOfElements?: number; + pageable?: PageableObject; + /** @format int32 */ + size?: number; + sort?: SortObject[]; + /** @format int64 */ + totalElements?: number; + /** @format int32 */ + totalPages?: number; +} + export interface PageMitigation { content?: Mitigation[]; empty?: boolean; @@ -11011,6 +11083,7 @@ export interface SecurityPlatform { asset_ips?: string[]; asset_linked_person?: string; asset_mac_addresses?: string[]; + asset_markings?: string[]; asset_metadata?: Record; /** @minLength 1 */ asset_name: string; diff --git a/openaev-front/src/utils/hooks/useMarkingDefinitions.ts b/openaev-front/src/utils/hooks/useMarkingDefinitions.ts new file mode 100644 index 00000000000..604132a8159 --- /dev/null +++ b/openaev-front/src/utils/hooks/useMarkingDefinitions.ts @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react'; + +import { searchMarkingDefinitions } from '../../actions/markings/marking-definition-actions'; +import { type MarkingDefinitionOutput } from '../api-types'; + +/** + * Loads every marking definition once and indexes it by id. + * + * Markings are not held in the Redux store (unlike tags, which resolve through `helper.getTag`), so + * a list rendering `asset_markings` has to resolve the ids itself. Fetching here — once per page — + * rather than inside the chip component keeps a 50-row list at one request instead of fifty. + * + * The definition set is small and effectively static (nine seeded TLP/PAP levels per tenant), so a + * single generous page is enough; there is nothing to paginate through. + */ +const useMarkingDefinitions = (): Record => { + const [definitions, setDefinitions] = useState>({}); + + useEffect(() => { + let cancelled = false; + searchMarkingDefinitions({ + page: 0, + size: 200, + }) + .then((result: { data?: { content?: MarkingDefinitionOutput[] } }) => { + if (cancelled) { + return; + } + const content = result?.data?.content ?? []; + setDefinitions(Object.fromEntries(content.map(marking => [marking.marking_id, marking]))); + }) + // A failed lookup must not break the list: ItemMarkings renders "-" for ids it cannot + // resolve, so the column degrades rather than throwing. + .catch(() => { + if (!cancelled) { + setDefinitions({}); + } + }); + return () => { + cancelled = true; + }; + }, []); + + return definitions; +}; + +export default useMarkingDefinitions; diff --git a/openaev-front/src/utils/lang/de.json b/openaev-front/src/utils/lang/de.json index d0bababb994..922b3fe1f66 100644 --- a/openaev-front/src/utils/lang/de.json +++ b/openaev-front/src/utils/lang/de.json @@ -2198,6 +2198,7 @@ "Mark as done": "Markieren als erledigt", "Mark as read": "Als gelesen markieren", "Mark as unread": "Als ungelesen markieren", + "Markings": "Markierungen", "Massive operations": "Massenoperationen", "Match a specific brand": "Match a specific brand", "Matches what recipients see": "Entspricht dem, was Empfänger sehen", diff --git a/openaev-front/src/utils/lang/en.json b/openaev-front/src/utils/lang/en.json index 6c63c8352f4..868afb5649d 100644 --- a/openaev-front/src/utils/lang/en.json +++ b/openaev-front/src/utils/lang/en.json @@ -2198,6 +2198,7 @@ "Mark as done": "Mark as done", "Mark as read": "Mark as read", "Mark as unread": "Mark as unread", + "Markings": "Markings", "Massive operations": "Massive operations", "Match a specific brand": "Match a specific brand", "Matches what recipients see": "Matches what recipients see", diff --git a/openaev-front/src/utils/lang/es.json b/openaev-front/src/utils/lang/es.json index 4a3c4a3ca81..86ae9c3f296 100644 --- a/openaev-front/src/utils/lang/es.json +++ b/openaev-front/src/utils/lang/es.json @@ -2198,6 +2198,7 @@ "Mark as done": "Marcar como hecho", "Mark as read": "Marcar como leido", "Mark as unread": "Marcar como no leido", + "Markings": "Marcados", "Massive operations": "Operaciones masivas", "Match a specific brand": "Match a specific brand", "Matches what recipients see": "Coincide con lo que ven los destinatarios", diff --git a/openaev-front/src/utils/lang/fr.json b/openaev-front/src/utils/lang/fr.json index 57b825d0439..e86b6bac7ed 100644 --- a/openaev-front/src/utils/lang/fr.json +++ b/openaev-front/src/utils/lang/fr.json @@ -2198,6 +2198,7 @@ "Mark as done": "Marquer comme fait", "Mark as read": "Marquer comme lu", "Mark as unread": "Marquer comme non lu", + "Markings": "Marquages", "Massive operations": "Opérations massives", "Match a specific brand": "Reproduire une marque specifique", "Matches what recipients see": "Correspond à ce que voient les destinataires", diff --git a/openaev-front/src/utils/lang/it.json b/openaev-front/src/utils/lang/it.json index 5942a699100..bfcaa3e6874 100644 --- a/openaev-front/src/utils/lang/it.json +++ b/openaev-front/src/utils/lang/it.json @@ -2198,6 +2198,7 @@ "Mark as done": "Contrassegnare come fatto", "Mark as read": "Segna come letto", "Mark as unread": "Segna come non letto", + "Markings": "Marcature", "Massive operations": "Operazioni massive", "Match a specific brand": "Match a specific brand", "Matches what recipients see": "Corrisponde a ciò che vedono i destinatari", diff --git a/openaev-front/src/utils/lang/ja.json b/openaev-front/src/utils/lang/ja.json index 674ec6da141..91fbee61dbb 100644 --- a/openaev-front/src/utils/lang/ja.json +++ b/openaev-front/src/utils/lang/ja.json @@ -2198,6 +2198,7 @@ "Mark as done": "完了マーク", "Mark as read": "既読にする", "Mark as unread": "未読にする", + "Markings": "マーキング", "Massive operations": "大規模操作", "Match a specific brand": "Match a specific brand", "Matches what recipients see": "受信者が見る内容と一致", diff --git a/openaev-front/src/utils/lang/ko.json b/openaev-front/src/utils/lang/ko.json index cffe1e2ed96..28297a1221e 100644 --- a/openaev-front/src/utils/lang/ko.json +++ b/openaev-front/src/utils/lang/ko.json @@ -2198,6 +2198,7 @@ "Mark as done": "완료된 것으로 표시", "Mark as read": "읽음으로 표시", "Mark as unread": "읽지 않음으로 표시", + "Markings": "마킹", "Massive operations": "대량 작업", "Match a specific brand": "Match a specific brand", "Matches what recipients see": "수신자에게 보이는 내용과 일치", diff --git a/openaev-front/src/utils/lang/ru.json b/openaev-front/src/utils/lang/ru.json index dddccdc351e..7357eb6ab70 100644 --- a/openaev-front/src/utils/lang/ru.json +++ b/openaev-front/src/utils/lang/ru.json @@ -2198,6 +2198,7 @@ "Mark as done": "Отметить как сделанное", "Mark as read": "Отметить как прочитанное", "Mark as unread": "Отметить как непрочитанное", + "Markings": "Маркировки", "Massive operations": "Массовые операции", "Match a specific brand": "Match a specific brand", "Matches what recipients see": "Совпадает с тем, что видят получатели", diff --git a/openaev-front/src/utils/lang/zh.json b/openaev-front/src/utils/lang/zh.json index 52e7ca74341..5b57155a522 100644 --- a/openaev-front/src/utils/lang/zh.json +++ b/openaev-front/src/utils/lang/zh.json @@ -2198,6 +2198,7 @@ "Mark as done": "标记已完成", "Mark as read": "标记为已读", "Mark as unread": "标记为未读", + "Markings": "标记", "Massive operations": "批量操作", "Match a specific brand": "Match a specific brand", "Matches what recipients see": "与收件人看到的内容一致", diff --git a/openaev-model/src/main/java/io/openaev/aop/TenantScopeTransactionAspect.java b/openaev-model/src/main/java/io/openaev/aop/TenantScopeTransactionAspect.java index e1389d72bdd..11efff56a17 100644 --- a/openaev-model/src/main/java/io/openaev/aop/TenantScopeTransactionAspect.java +++ b/openaev-model/src/main/java/io/openaev/aop/TenantScopeTransactionAspect.java @@ -1,5 +1,7 @@ package io.openaev.aop; +import io.openaev.context.MarkingCtx; +import io.openaev.context.MarkingScopeSupplier; import io.openaev.context.TxCtx; import jakarta.persistence.EntityManager; import java.util.Arrays; @@ -9,6 +11,7 @@ import org.aspectj.lang.JoinPoint; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Before; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; @@ -34,6 +37,18 @@ * never reach the method. The integration test pins this down for the propagations the application * actually uses (REQUIRED, REQUIRES_NEW, read-only). * + *

Both scope dimensions are written here, together: {@code app.current_tenants} from the {@link + * TxCtx} argument, and {@code app.current_markings} derived from the caller through {@link + * MarkingScopeSupplier}. Tenant is passed because a caller legitimately chooses which of their + * tenants to act in; clearance is derived because nobody chooses their own. Writing both in one + * place is what keeps "a transaction's scope is set once" a single rule rather than two that have + * to agree. + * + *

⚠️ A {@code @Transactional} method with no {@link TxCtx} parameter writes neither + * setting. For marking that means the transaction sees only unmarked rows of any + * marking-active table — a partial, silent narrowing rather than an obvious empty result. Harmless + * while no table is marking-active; it is the blast radius that activating one has to account for. + * *

Within one transaction the scope is set once. A nested {@code @Transactional} method that * would change an already-set scope is refused (a programming error), while one that * repeats the same set of tenants is tolerated. A nested method that needs a different scope must @@ -48,6 +63,13 @@ public class TenantScopeTransactionAspect { private final EntityManager entityManager; + /** + * Optional so the aspect keeps working in contexts that do not wire the API layer (model tests, + * and any slice without it). Absent means no marking scope is written at all, which leaves the + * setting empty — see the class javadoc for what that implies once a table is marking-active. + */ + private final ObjectProvider markingScopeSupplier; + @Before( "@annotation(org.springframework.transaction.annotation.Transactional) || " + "@annotation(jakarta.transaction.Transactional)") @@ -70,6 +92,20 @@ public void applyScope(JoinPoint joinPoint) { .formatted(current, joinPoint.getSignature().toShortString(), desired)); } setScope(desired); + setMarkingScope(markingScopeFor(ctx)); + } + + /** + * Derives the caller's clearance. Deliberately fail-closed on both the absent-supplier and the + * absent-principal paths: an empty setting still admits unmarked rows, so the failure mode is a + * narrower result set, never a wider one. + */ + private String markingScopeFor(TxCtx ctx) { + MarkingScopeSupplier supplier = markingScopeSupplier.getIfAvailable(); + if (supplier == null) { + return MarkingCtx.none().toGuc(); + } + return supplier.clearanceFor(ctx).toGuc(); } private static Set tenants(String guc) { @@ -90,6 +126,13 @@ private void setScope(String scope) { .getSingleResult(); } + private void setMarkingScope(String scope) { + entityManager + .createNativeQuery("SELECT set_config('app.current_markings', :scope, true)") + .setParameter("scope", scope) + .getSingleResult(); + } + private static TxCtx findTxCtx(Object[] args) { if (args == null) { return null; diff --git a/openaev-model/src/main/java/io/openaev/context/MarkingCtx.java b/openaev-model/src/main/java/io/openaev/context/MarkingCtx.java new file mode 100644 index 00000000000..e03810b4205 --- /dev/null +++ b/openaev-model/src/main/java/io/openaev/context/MarkingCtx.java @@ -0,0 +1,99 @@ +package io.openaev.context; + +import java.util.Collection; +import java.util.List; + +/** + * Marking clearance carried by a database transaction: the set of marking ids the caller holds. + * + *

Deliberately shaped like {@link TxCtx} — three states, never {@code null}, serialized to a + * comma-separated GUC — because both are scope dimensions read by the same statement inspector. + * {@link #toGuc()} feeds {@code set_config('app.current_markings', …, true)}, read back by the + * {@code is_marking_set_allowed(marking_ids)} SQL function. + * + *

The one place the analogy breaks, and it matters. An empty tenant scope denies every + * row ({@code can_access_tenant} is false for all of them), so {@link TxCtx.Missing} means "access + * denied". An empty marking clearance denies only marked rows: the predicate is set + * containment, and the empty set is contained in the empty set, so unmarked rows stay visible. That + * is why the state here is called {@link None} rather than "missing" — holding no clearance is a + * normal, safe state (it is what every user starts with), not an error. Fail-closed for marking + * means "see less", not "see nothing". + * + *

Ordinality is already resolved by the time a value of this type exists: {@code TLP:AMBER} + * implies {@code TLP:GREEN} and {@code TLP:CLEAR}, and that expansion happens in Java (see {@code + * MarkingScopeResolver}). The set here is therefore flat — every id the caller holds, not + * just the highest per type — which is what lets the SQL predicate be a plain containment test with + * no notion of order. + */ +public sealed interface MarkingCtx permits MarkingCtx.None, MarkingCtx.Restricted, MarkingCtx.All { + + /** Value for {@code set_config('app.current_markings', …, true)}; never {@code null}. */ + String toGuc(); + + /** No marking held: marked rows are hidden, unmarked rows remain visible. */ + static MarkingCtx none() { + return None.INSTANCE; + } + + /** Clearance restricted to an explicit, non-empty set of marking ids. */ + static MarkingCtx forMarkings(Collection markingIds) { + return markingIds.isEmpty() ? none() : new Restricted(List.copyOf(markingIds)); + } + + /** + * The intention "this work must see every marked row" — system identity, the marking counterpart + * of {@link TxCtx#allTenants()}. Not a wildcard: it is resolved into an explicit {@link + * Restricted} list of the tenant's marking ids when the scope is set, and only {@code + * TenantScopedTransaction} does that. Background jobs take this by default so that activating a + * table stays a no-op for them. + */ + static MarkingCtx all() { + return All.INSTANCE; + } + + record None() implements MarkingCtx { + static final None INSTANCE = new None(); + + /** Empty string: contains no marking, so only unmarked rows satisfy the predicate. */ + @Override + public String toGuc() { + return ""; + } + } + + /** An unresolved intention: it cannot reach the scope channel, only its resolution can. */ + record All() implements MarkingCtx { + static final All INSTANCE = new All(); + + @Override + public String toGuc() { + throw new IllegalStateException( + "all() is an unresolved intention: it cannot be serialized to the scope channel. Only" + + " TenantScopedTransaction resolves it into the explicit marking list of the" + + " tenant(s) in scope; it is not usable on the HTTP path."); + } + } + + record Restricted(List markingIds) implements MarkingCtx { + public Restricted { + markingIds = List.copyOf(markingIds); + if (markingIds.isEmpty()) { + throw new IllegalArgumentException( + "marking clearance must not be empty; use none() instead"); + } + for (String id : markingIds) { + if (id.isBlank()) { + throw new IllegalArgumentException("marking id must not be blank"); + } + if (id.indexOf(',') >= 0) { + throw new IllegalArgumentException("marking id must not contain ','"); + } + } + } + + @Override + public String toGuc() { + return String.join(",", markingIds); + } + } +} diff --git a/openaev-model/src/main/java/io/openaev/context/MarkingScopeSupplier.java b/openaev-model/src/main/java/io/openaev/context/MarkingScopeSupplier.java new file mode 100644 index 00000000000..2797930832c --- /dev/null +++ b/openaev-model/src/main/java/io/openaev/context/MarkingScopeSupplier.java @@ -0,0 +1,38 @@ +package io.openaev.context; + +/** + * Supplies the marking clearance of the current caller, for the tenant scope of the transaction + * being opened. + * + *

Why this interface exists. The scope aspect lives in {@code openaev-model}, but a + * caller's clearance is derived from the authenticated principal and a cache that both live in + * {@code openaev-api}, and {@code openaev-model} does not depend on {@code openaev-api}. Rather + * than split scope-setting across two aspects — which would make their relative order load-bearing + * and duplicate the "a transaction's scope is set once" rule — the model declares what it needs and + * the API supplies it. + * + *

The clearance is derived, never passed. Marking scope is not a REST parameter: adding + * it to endpoint signatures would put a security boundary in the hands of every controller author, + * and a forgotten parameter would be a silent widening. The tenant dimension can afford to be an + * explicit argument because the caller legitimately chooses which of their tenants to act + * in; nobody chooses their own clearance. + * + *

Implementations must be fail-closed: with no principal, or no resolvable clearance, + * return {@link MarkingCtx#none()}. Note what that does and does not mean here — an empty clearance + * still sees unmarked rows, so it degrades to "see less", not "see nothing". Never return {@link + * MarkingCtx#all()}: it is an unresolved intention reserved for the background primitive and throws + * on serialization. + */ +@FunctionalInterface +public interface MarkingScopeSupplier { + + /** + * The clearance to write into {@code app.current_markings} for this transaction. + * + * @param tenantScope the tenant scope already resolved for the transaction. Clearance is + * per-tenant — a marking definition belongs to exactly one tenant — so the answer depends on + * it. This is the one place the two scope dimensions meet, and it is a dependency of the + * clearance computation, not of the SQL rewrite. + */ + MarkingCtx clearanceFor(TxCtx tenantScope); +} diff --git a/openaev-model/src/main/java/io/openaev/context/TenantScopedTransaction.java b/openaev-model/src/main/java/io/openaev/context/TenantScopedTransaction.java index 23ed65c41b0..e2e11660cec 100644 --- a/openaev-model/src/main/java/io/openaev/context/TenantScopedTransaction.java +++ b/openaev-model/src/main/java/io/openaev/context/TenantScopedTransaction.java @@ -1,5 +1,6 @@ package io.openaev.context; +import io.openaev.database.repository.MarkingDefinitionRepository; import jakarta.persistence.EntityManager; import java.util.LinkedHashMap; import java.util.List; @@ -55,6 +56,7 @@ public class TenantScopedTransaction { private final PlatformTransactionManager transactionManager; private final EntityManager entityManager; private final TenantScopeIntentionResolver intentionResolver; + private final MarkingDefinitionRepository markingDefinitionRepository; /** Opens a background transaction carrying {@code ctx}; refuses to run inside an active one. */ public T execute(TxCtx ctx, Supplier work) { @@ -197,7 +199,9 @@ private T run(TxCtx ctx, int propagation, Supplier work) { status -> { // Resolved inside the transaction: an intention is re-resolved at every short // transaction, so a long-running job naturally sees tenants created in between. - setScope(intentionResolver.resolve(ctx).toGuc()); + TxCtx resolved = intentionResolver.resolve(ctx); + setScope(resolved.toGuc()); + setMarkingScope(systemClearance(resolved).toGuc()); return work.get(); }); } @@ -211,10 +215,48 @@ private static void requireScope(TxCtx ctx) { } } + /** + * The clearance a background transaction runs at: every marking of the tenants in scope. + * + *

Note the asymmetry with the tenant dimension, because it is deliberate rather than an + * oversight. The job is narrowed to its tenants, because a tenant is a real boundary it + * must respect. It is widened to all markings, because a marking is a boundary between + * users, and a scheduler is not a user. This is the {@code isAdminOrBypass()} equivalent, + * expressed in the primitive. Concretely it means activating a table on marking is a no-op for + * background work: an inject targeting a TLP:RED asset still executes. + * + *

Assigned, never derived. Background work has no principal to derive from, and jobs that + * borrow threads from a shared pool (see {@code InjectsExecutionJob}) could otherwise inherit + * whatever clearance the borrowed thread happened to carry — non-deterministic, and occasionally + * less than the job needs. + * + *

A tenant with no marking definitions yields {@link MarkingCtx#none()}, which is correct + * rather than degraded: with nothing marked, every row is unmarked and therefore visible. + */ + private MarkingCtx systemClearance(TxCtx resolved) { + if (!(resolved instanceof TxCtx.Restricted restricted)) { + // resolve() has already refused Missing and expanded AllTenants, so this is unreachable. + throw new IllegalStateException( + "a background transaction must carry an explicit tenant scope before its marking scope" + + " can be resolved; got " + + resolved.getClass().getSimpleName()); + } + List markingIds = + markingDefinitionRepository.findAllIdsByTenantIds(restricted.tenantIds()); + return markingIds.isEmpty() ? MarkingCtx.none() : MarkingCtx.forMarkings(markingIds); + } + private void setScope(String scope) { entityManager .createNativeQuery("SELECT set_config('app.current_tenants', :scope, true)") .setParameter("scope", scope) .getSingleResult(); } + + private void setMarkingScope(String scope) { + entityManager + .createNativeQuery("SELECT set_config('app.current_markings', :scope, true)") + .setParameter("scope", scope) + .getSingleResult(); + } } diff --git a/openaev-model/src/main/java/io/openaev/database/model/Asset.java b/openaev-model/src/main/java/io/openaev/database/model/Asset.java index 9d500115f0b..b5a3a8b057f 100644 --- a/openaev-model/src/main/java/io/openaev/database/model/Asset.java +++ b/openaev-model/src/main/java/io/openaev/database/model/Asset.java @@ -231,6 +231,31 @@ public void setLinkedPerson(String linkedPerson) { @JsonProperty("asset_mac_addresses") private String[] macAddresses; + /** + * The markings this asset carries — its sensitivity labels, not a clearance. + * + *

🔴 Read filtering does not come from this field. It comes from the statement + * inspector rewriting every query on {@code assets} with {@code + * is_marking_set_allowed(marking_ids)} once the table is on {@code + * openaev.marking.active-tables}. That is the whole point of the design: no repository or service + * read code knows markings exist. This mapping exists so the set can be written and + * displayed, nothing more. + * + *

Semantics of the column, all of which follow from the {@code <@} predicate: a row is visible + * only when the reader holds every marking on it (AND, the STIX reading), and {@code null} + * or empty means unmarked and therefore visible to everyone — a marking can only ever reduce + * visibility, never grant it. + * + *

Stored inline as {@code text[]} rather than through a join table (design §3.2, Option 2), so + * there is no foreign key to {@code marking_definitions}. Validity of the ids is therefore + * an application concern: writes must go through the marking write guard, and deleting a + * definition must scrub the arrays (design §5.7). + */ + @Type(StringArrayType.class) + @Column(name = "marking_ids", columnDefinition = "text[]") + @JsonProperty("asset_markings") + private String[] markingIds; + public void setHostname(String hostname) { // Locale.ROOT keeps hostname normalization stable regardless of the JVM default locale // (e.g. the Turkish dotless-i), since hostnames are not locale-specific text. diff --git a/openaev-model/src/main/java/io/openaev/database/model/Capability.java b/openaev-model/src/main/java/io/openaev/database/model/Capability.java index 422300f3ddd..aa4692d02af 100644 --- a/openaev-model/src/main/java/io/openaev/database/model/Capability.java +++ b/openaev-model/src/main/java/io/openaev/database/model/Capability.java @@ -334,6 +334,8 @@ MANAGE_SECURITY_PLATFORMS, pair(ResourceType.SECURITY_PLATFORM, Action.DELETE)), pair(ResourceType.VULNERABILITY, Action.SEARCH), pair(ResourceType.ORGANIZATION, Action.READ), pair(ResourceType.ORGANIZATION, Action.SEARCH), + pair(ResourceType.MARKING_DEFINITION, Action.READ), + pair(ResourceType.MARKING_DEFINITION, Action.SEARCH), pair(ResourceType.COLLECTOR, Action.READ), pair(ResourceType.COLLECTOR, Action.SEARCH), pair(ResourceType.INJECTOR, Action.READ), @@ -372,6 +374,8 @@ ACCESS_TAGS, pair(ResourceType.TAG, Action.WRITE), pair(ResourceType.TAG, Action pair(ResourceType.VULNERABILITY, Action.CREATE), pair(ResourceType.ORGANIZATION, Action.WRITE), pair(ResourceType.ORGANIZATION, Action.CREATE), + pair(ResourceType.MARKING_DEFINITION, Action.WRITE), + pair(ResourceType.MARKING_DEFINITION, Action.CREATE), pair(ResourceType.MAPPER, Action.WRITE), pair(ResourceType.MAPPER, Action.CREATE), pair(ResourceType.MAPPER, Action.DUPLICATE), @@ -398,6 +402,7 @@ ACCESS_TAGS, pair(ResourceType.TAG, Action.WRITE), pair(ResourceType.TAG, Action pair(ResourceType.KILL_CHAIN_PHASE, Action.DELETE), pair(ResourceType.VULNERABILITY, Action.DELETE), pair(ResourceType.ORGANIZATION, Action.DELETE), + pair(ResourceType.MARKING_DEFINITION, Action.DELETE), pair(ResourceType.MAPPER, Action.DELETE), pair(ResourceType.COLLECTOR, Action.DELETE), pair(ResourceType.INJECTOR, Action.DELETE), diff --git a/openaev-model/src/main/java/io/openaev/database/model/Group.java b/openaev-model/src/main/java/io/openaev/database/model/Group.java index fc5108d2211..7c78f94b3bf 100644 --- a/openaev-model/src/main/java/io/openaev/database/model/Group.java +++ b/openaev-model/src/main/java/io/openaev/database/model/Group.java @@ -81,6 +81,33 @@ public class Group implements DualScopeBase { @Fetch(value = FetchMode.SUBSELECT) private List roles = new ArrayList<>(); + /** + * The markings this group grants its members — the source of their clearance. + * + *

Mapped even though the clearance read path deliberately does not use JPA: {@code + * MarkingClearanceCacheManager} runs before any transaction exists and must not pin a Hibernate + * session, so it queries {@code groups_markings} with {@code JdbcTemplate}. The write path + * has no such constraint — it runs inside a normal transactional service — so it uses the ORM + * like every other group association. The two agree on the table, not on the access mechanism. + * + *

🔴 A change here changes what every member of the group may see, so every write must be + * followed by {@code MarkingClearanceCacheManager#evictForUsers}: the cached clearance is pure + * set containment and never re-consults this table, so a stale entry fails open. + * + *

EAGER + SUBSELECT to match {@link #users} and {@link #roles}: one extra query per batch of + * groups, and the collection is serialized with the group. + */ + @Schema(implementation = String[].class) + @ManyToMany(fetch = FetchType.EAGER) + @JoinTable( + name = "groups_markings", + joinColumns = @JoinColumn(name = "group_id"), + inverseJoinColumns = @JoinColumn(name = "marking_id")) + @JsonSerialize(using = MultiIdListSerializer.class) + @JsonProperty("group_markings") + @Fetch(value = FetchMode.SUBSELECT) + private List markings = new ArrayList<>(); + @ManyToOne @JoinColumn(name = "tenant_id", updatable = false) @JsonIgnore diff --git a/openaev-model/src/main/java/io/openaev/database/model/MarkingDefinition.java b/openaev-model/src/main/java/io/openaev/database/model/MarkingDefinition.java new file mode 100644 index 00000000000..05f9f27ded5 --- /dev/null +++ b/openaev-model/src/main/java/io/openaev/database/model/MarkingDefinition.java @@ -0,0 +1,144 @@ +package io.openaev.database.model; + +import static java.time.Instant.now; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.openaev.annotation.Queryable; +import io.openaev.database.audit.ModelBaseListener; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.Instant; +import java.util.Objects; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; +import org.hibernate.annotations.UuidGenerator; + +/** + * A marking definition — one level of one classification scale, e.g. {@code TLP:RED}. + * + *

Markings are the vocabulary a clearance is expressed in. A group is granted a set of + * markings ({@code groups_markings}); a row is attached a set of markings ({@code marking_ids}); + * the row is visible when its set is contained in the reader's clearance. + * + *

{@link #type} groups markings into independent scales (TLP, PAP, or a tenant's own), and + * {@link #order} ranks them within a scale. Holding a level implies holding every lower level of + * the same scale, and scales never imply one another — a TLP clearance says nothing about + * PAP. + * + *

Tenant-scoped on v2 (statement inspector + {@code can_access_tenant}); there is + * deliberately no Hibernate {@code @Filter}. The table is also never marking-filtered: it is what a + * clearance is resolved from, so filtering it would make resolution depend on its own + * result. + */ +@Entity +@Table(name = "marking_definitions") +@EntityListeners({ModelBaseListener.class}) +public class MarkingDefinition implements TenantBase { + + public static final String TYPE_TLP = "TLP"; + public static final String TYPE_PAP = "PAP"; + + @Setter + @Id + @Column(name = "marking_id") + @GeneratedValue(generator = "UUID") + @UuidGenerator + @JsonProperty("marking_id") + @NotBlank + @Schema(description = "Unique identifier of the marking definition") + private String id; + + @Getter + @Setter + @Column(name = "marking_type") + @JsonProperty("marking_type") + @Queryable(filterable = true, searchable = true, sortable = true) + @NotBlank + @Schema(description = "Classification scale this marking belongs to, e.g. TLP or PAP") + private String type; + + @Getter + @Setter + @Column(name = "marking_name") + @JsonProperty("marking_name") + @Queryable(filterable = true, searchable = true, sortable = true) + @NotBlank + @Schema(description = "Name of the marking, unique within the tenant, e.g. TLP:RED") + private String name; + + @Getter + @Setter + @Column(name = "marking_order") + @JsonProperty("marking_order") + @Queryable(sortable = true) + @NotNull + @Schema( + description = + "Rank within the scale — higher is more restrictive. Holding a level implies holding" + + " every lower level of the same scale.") + private Integer order; + + @Getter + @Setter + @Column(name = "marking_color") + @JsonProperty("marking_color") + @Schema(description = "Display colour, as a hex code") + private String color; + + @Getter + @Column(name = "marking_created_at") + @JsonProperty("marking_created_at") + @NotNull + @CreationTimestamp + private Instant createdAt = now(); + + @Getter + @Column(name = "marking_updated_at") + @JsonProperty("marking_updated_at") + @NotNull + @UpdateTimestamp + private Instant updatedAt = now(); + + @ManyToOne + @JoinColumn(name = "tenant_id", updatable = false, nullable = false) + @JsonIgnore + @Getter + @Setter + private Tenant tenant; + + @Getter(onMethod_ = @JsonIgnore) + @Transient + private final ResourceType resourceType = ResourceType.MARKING_DEFINITION; + + @JsonIgnore + @Override + public boolean isUserHasAccess(User user) { + return true; + } + + @Override + public String getId() { + return id; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || !Base.class.isAssignableFrom(o.getClass())) { + return false; + } + return id != null && id.equals(((Base) o).getId()); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } +} diff --git a/openaev-model/src/main/java/io/openaev/database/model/ResourceType.java b/openaev-model/src/main/java/io/openaev/database/model/ResourceType.java index 9b06a3c8318..d5dc2649952 100644 --- a/openaev-model/src/main/java/io/openaev/database/model/ResourceType.java +++ b/openaev-model/src/main/java/io/openaev/database/model/ResourceType.java @@ -35,6 +35,7 @@ public enum ResourceType { JOB, TAG, TAG_RULE, + MARKING_DEFINITION, KILL_CHAIN_PHASE, ATTACK_PATTERN, ASSET_GROUP, diff --git a/openaev-model/src/main/java/io/openaev/database/repository/AiTargetRepository.java b/openaev-model/src/main/java/io/openaev/database/repository/AiTargetRepository.java index 790b00dead1..2bbec2f9804 100644 --- a/openaev-model/src/main/java/io/openaev/database/repository/AiTargetRepository.java +++ b/openaev-model/src/main/java/io/openaev/database/repository/AiTargetRepository.java @@ -40,7 +40,7 @@ public interface AiTargetRepository @Query( "SELECT DISTINCT a FROM Asset a " + "WHERE a.category = io.openaev.database.model.AssetCategory.AI_TARGET AND " - + "(:name IS NULL OR lower(a.name) LIKE lower(concat('%', cast(coalesce(:name, '') as string), '%')))") + + "(:name IS NULL OR lower(a.name) LIKE concat('%', lower(cast(coalesce(:name, '') as string)), '%'))") List findAllByName(String name); @Query( diff --git a/openaev-model/src/main/java/io/openaev/database/repository/AssetRepository.java b/openaev-model/src/main/java/io/openaev/database/repository/AssetRepository.java index c73e24f3932..cc08e120f73 100644 --- a/openaev-model/src/main/java/io/openaev/database/repository/AssetRepository.java +++ b/openaev-model/src/main/java/io/openaev/database/repository/AssetRepository.java @@ -171,13 +171,20 @@ public interface AssetRepository * to any asset, so filter options (e.g. notification trigger criteria) must propose the full * inventory, not only endpoints. The category is returned so pickers can group options by asset * category. JPQL (not native) so the tenant filter still applies. The {@code name} parameter must - * be non-null (empty string matches everything): a null bind parameter inside {@code - * lower(concat(...))} is typed as bytea by PostgreSQL and fails. + * be non-null (empty string matches everything): a null bind parameter inside {@code concat(...)} + * is typed as bytea by PostgreSQL and fails. + * + *

{@code lower()} is applied to the bind parameter, not to the whole concatenation: Hibernate + * renders JPQL {@code concat} as the {@code ||} operator, and JSqlParser 5.2 cannot parse a + * function call whose argument is a concatenation containing a bind parameter ({@code + * lower('%'||?||'%')}). Because {@code assets} is a scope-filtered table, every statement against + * it goes through {@link io.openaev.config.ScopeStatementInspector}, which is fail-closed and + * refuses SQL it cannot parse. Keeping {@code ||} as the outermost operator keeps it parseable. */ @Query( "SELECT a.id, a.name, a.category FROM Asset a " + "WHERE a.type <> 'SecurityPlatform' " - + "AND lower(a.name) LIKE lower(concat('%', :name, '%')) " + + "AND lower(a.name) LIKE concat('%', lower(:name), '%') " // id tie-breaker: names are not unique, and a fixed page size over a // non-deterministic order would return unstable option subsets + "ORDER BY a.name, a.id") diff --git a/openaev-model/src/main/java/io/openaev/database/repository/EndpointRepository.java b/openaev-model/src/main/java/io/openaev/database/repository/EndpointRepository.java index ccba22a8ca2..161dd381818 100644 --- a/openaev-model/src/main/java/io/openaev/database/repository/EndpointRepository.java +++ b/openaev-model/src/main/java/io/openaev/database/repository/EndpointRepository.java @@ -50,6 +50,16 @@ List findByHostnameAndAtleastOneIp( @NotNull final @Param("ips") String[] ips, @NotNull final @Param("tenantId") String tenantId); + /** + * The stored / requested MAC overlap is expressed with the array operator {@code &&} rather than + * with {@code unnest(...)} in a FROM clause. {@code assets} is a scope-filtered table, so every + * statement against it goes through {@link io.openaev.config.ScopeStatementInspector}, which is + * fail-closed and refuses table functions it has not reviewed. Normalising the requested MACs + * through {@code array_to_string} / {@code string_to_array} keeps the whole predicate expressible + * as scalar functions. The {@code cardinality} guard preserves the previous semantics for an + * empty request: {@code string_to_array('', ',')} yields {@code {''}}, which would otherwise + * match a stored empty string. + */ @Query( value = "select e.*" @@ -57,8 +67,9 @@ List findByHostnameAndAtleastOneIp( + " from assets e where e.asset_type = '" + AssetType.Values.ENDPOINT_TYPE + "' and LOWER(e.asset_hostname) = LOWER(:hostname) and e.tenant_id = :tenantId " - + "and exists (select 1 from unnest(e.asset_mac_addresses) as mac " - + "where mac = any(select LOWER(REPLACE(REPLACE(m, ':', ''), '-', '')) from unnest(cast(:macAddresses as text[])) as m))", + + "and cardinality(cast(:macAddresses as text[])) > 0 " + + "and e.asset_mac_addresses && string_to_array(" + + "LOWER(REPLACE(REPLACE(array_to_string(cast(:macAddresses as text[]), ','), ':', ''), '-', '')), ',')", nativeQuery = true) List findByHostnameAndAtleastOneMacAddress( @Param("hostname") String hostname, @@ -96,7 +107,7 @@ List findByExternalReference( + " :simulationOrScenarioId is NULL AND i.exercise.id is NULL AND i.scenario.id IS NULL" + " OR (i.exercise.id = :simulationOrScenarioId" + " OR i.scenario.id = :simulationOrScenarioId)" - + " ) AND (:name IS NULL OR lower(a.name) LIKE lower(concat('%', cast(coalesce(:name, '') as string), '%')))" + + " ) AND (:name IS NULL OR lower(a.name) LIKE concat('%', lower(cast(coalesce(:name, '') as string)), '%'))" // injects_assets may now reference non-endpoint assets (e.g. AI targets) + " AND TYPE(a) = Endpoint" + " AND i.tenant.id = :#{#tenantContext.currentTenant}") diff --git a/openaev-model/src/main/java/io/openaev/database/repository/MarkingDefinitionRepository.java b/openaev-model/src/main/java/io/openaev/database/repository/MarkingDefinitionRepository.java new file mode 100644 index 00000000000..7b86b78253b --- /dev/null +++ b/openaev-model/src/main/java/io/openaev/database/repository/MarkingDefinitionRepository.java @@ -0,0 +1,45 @@ +package io.openaev.database.repository; + +import io.openaev.database.model.MarkingDefinition; +import jakarta.validation.constraints.NotNull; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +/** + * Tenant isolation is handled by the v2 statement inspector, so these derived queries carry no + * tenant predicate of their own — adding one would double-filter. + */ +@Repository +public interface MarkingDefinitionRepository + extends CrudRepository, JpaSpecificationExecutor { + + @NotNull + Optional findById(@NotNull String id); + + /** + * Returns a list, not an {@link Optional}: the unique index is composite on {@code (marking_name, + * tenant_id)}, so several tenants legitimately own a marking of the same name. Any context where + * the statement inspector is not scoping this table would make a single-result finder throw. + */ + List findAllByName(@NotNull String name); + + List findAllByTypeOrderByOrderAsc(@NotNull String type); + + /** + * Every marking id defined in the given tenants — the system clearance a background transaction + * runs at. + * + *

The tenant predicate is explicit even though the statement inspector would add its own by + * the time this runs: the caller is in the middle of establishing a scope, and a scope-resolution + * query that silently depends on the scope it is resolving is the kind of ordering assumption + * that breaks quietly. Belt and braces, on purpose. + */ + @Query("select m.id from MarkingDefinition m where m.tenant.id in :tenantIds") + List findAllIdsByTenantIds(@Param("tenantIds") Collection tenantIds); +} diff --git a/openaev-model/src/main/java/io/openaev/database/repository/SecurityPlatformRepository.java b/openaev-model/src/main/java/io/openaev/database/repository/SecurityPlatformRepository.java index 105dc2ce9f7..57be466a27b 100644 --- a/openaev-model/src/main/java/io/openaev/database/repository/SecurityPlatformRepository.java +++ b/openaev-model/src/main/java/io/openaev/database/repository/SecurityPlatformRepository.java @@ -77,7 +77,7 @@ Optional findByNameIgnoreCaseAndSecurityPlatformType( + "WHERE a.type = '" + AssetType.Values.SECURITY_PLATFORM_TYPE + "' AND " - + "(:name IS NULL OR lower(a.name) LIKE lower(concat('%', cast(coalesce(:name, '') as string), '%')))") + + "(:name IS NULL OR lower(a.name) LIKE concat('%', lower(cast(coalesce(:name, '') as string)), '%'))") List findAllByName(String name); @Query( diff --git a/openaev-model/src/main/java/io/openaev/database/repository/VulnerableEndpointRepository.java b/openaev-model/src/main/java/io/openaev/database/repository/VulnerableEndpointRepository.java index 2f219492683..a155f708ea2 100644 --- a/openaev-model/src/main/java/io/openaev/database/repository/VulnerableEndpointRepository.java +++ b/openaev-model/src/main/java/io/openaev/database/repository/VulnerableEndpointRepository.java @@ -91,7 +91,8 @@ ORDER BY GREATEST(e.exercise_updated_at, a.asset_updated_at, fm.max_finding) ASC JOIN injects i ON i.inject_exercise = rve.inject_exercise JOIN findings f ON f.finding_inject_id = i.inject_id AND f.finding_type = 'CVE' JOIN findings_assets fa ON f.finding_id = fa.finding_id AND fa.asset_id = a.asset_id - GROUP BY a.asset_id, rve.inject_exercise, e.exercise_updated_at, e.exercise_created_at, a.asset_updated_at + GROUP BY a.asset_id, rve.inject_exercise, e.exercise_updated_at, e.exercise_created_at, a.asset_updated_at, + a.asset_hostname, a.endpoint_platform, a.endpoint_is_eol, a.endpoint_arch, a.tenant_id ORDER BY GREATEST(e.exercise_updated_at, a.asset_updated_at, max(f.finding_updated_at)) ASC """, nativeQuery = true) diff --git a/openaev-model/src/main/java/io/openaev/database/specification/EndpointSpecification.java b/openaev-model/src/main/java/io/openaev/database/specification/EndpointSpecification.java index 42f44282719..5e85544d7c2 100644 --- a/openaev-model/src/main/java/io/openaev/database/specification/EndpointSpecification.java +++ b/openaev-model/src/main/java/io/openaev/database/specification/EndpointSpecification.java @@ -21,7 +21,11 @@ public static Specification findEndpointsForInjectionOrAgentlessEndpoi public static Specification findEndpointsForInjection() { return (root, query, criteriaBuilder) -> { Join agentsJoin = root.join("agents", JoinType.LEFT); - query.groupBy(root.get("id")); + // De-duplicates the rows multiplied by the LEFT JOIN. Deliberately DISTINCT and not + // GROUP BY(id): a scope-filtered table is rewritten into an inline view, which carries no + // primary key, so PostgreSQL can no longer infer the functional dependency that makes + // "GROUP BY id" with a full projection legal. + query.distinct(true); return criteriaBuilder.and( criteriaBuilder.isNull(agentsJoin.get("parent")), criteriaBuilder.isNull(agentsJoin.get("inject"))); @@ -30,7 +34,8 @@ public static Specification findEndpointsForInjection() { public static Specification findAgentlessEndpoints() { return (root, query, criteriaBuilder) -> { - query.groupBy(root.get("id")); + // No join here (isEmpty is a subquery), so no rows are multiplied and no de-duplication is + // needed at all. return criteriaBuilder.and(criteriaBuilder.isEmpty(root.get("agents"))); }; } @@ -39,7 +44,6 @@ public static Specification findEndpointsForAssetGroup( @NotNull final String assetGroupId) { return (root, query, criteriaBuilder) -> { Join assetGroupJoin = root.join("assetGroups", JoinType.LEFT); - query.groupBy(root.get("id")); query.distinct(true); return criteriaBuilder.and(criteriaBuilder.equal(assetGroupJoin.get("id"), assetGroupId)); };