diff --git a/backend/api/auth.py b/backend/api/auth.py index bd188351c..2c56c92e8 100644 --- a/backend/api/auth.py +++ b/backend/api/auth.py @@ -196,11 +196,12 @@ def build_auth_context(authorization: str | None = None) -> AuthContext: """ Build runtime identity from verified signed session material. - Client-supplied identity metadata is not authentication material. Only a - bearer token signed by the configured control-plane HMAC secret can supply - identity, role, organization, group, and workspace claims in the runtime - dependency path. Endpoint tests that need fixture identities must continue to - use explicit FastAPI dependency overrides. + Client-supplied identity metadata is not authentication material. Runtime + identity comes only from a bearer token verified by the configured OIDC/JWKS + provider or the control-plane HMAC compatibility secret. Admin roles are + accepted only from the verified OIDC authority; HMAC compatibility sessions + cannot assert admin membership. Endpoint tests that need fixture identities + must continue to use explicit FastAPI dependency overrides. """ payload, session_verifier = _verify_signed_session_payload(authorization) return _auth_context_from_session_payload(payload, session_verifier) @@ -374,7 +375,6 @@ def _verify_signed_session_token(token: str) -> tuple[dict[str, Any], SessionVer raise _authentication_error() try: payload = _decode_cached_oidc_session_payload(token) - _reject_signed_session_admin_payload(payload) return payload, "oidc" except Exception: raise _authentication_error() from None @@ -403,16 +403,15 @@ def _verify_signed_session_token(token: str) -> tuple[dict[str, Any], SessionVer raise _authentication_error() if not isinstance(payload, dict): raise _authentication_error() - _reject_signed_session_admin_payload(payload) + _reject_hmac_admin_payload(payload) return payload, "hmac" -def _reject_signed_session_admin_payload(payload: dict[str, Any]) -> None: +def _reject_hmac_admin_payload(payload: dict[str, Any]) -> None: + """Reject admin membership claims from the HMAC compatibility credential.""" role_claim = payload.get("role") if not isinstance(role_claim, str): raise _authentication_error() - # Admin roles require explicit server-side assignment, not externally - # supplied HMAC or enterprise OIDC session claims. if role_claim in ADMIN_ROLES: raise _authentication_error() @@ -512,7 +511,7 @@ def _auth_context_from_session_payload( if role_value not in ALLOWED_ROLES: raise _authentication_error() role = cast(RoleName, role_value) - if role in TENANT_ADMIN_ROLES and session_verifier not in ("server", "override"): + if role in ADMIN_ROLES and session_verifier not in ("oidc", "server", "override"): raise _authentication_error() organization_id = _optional_string_claim(payload, "org") if organization_id is None: @@ -542,4 +541,4 @@ async def get_current_workspace_id( async def get_current_user_role( auth_context: AuthContext = Depends(get_auth_context), ) -> str: - return auth_context.role + return auth_context.role \ No newline at end of file diff --git a/backend/api/prompts.py b/backend/api/prompts.py index c4d7008ea..408b096de 100644 --- a/backend/api/prompts.py +++ b/backend/api/prompts.py @@ -36,7 +36,6 @@ class PromptCreate(BaseModel): class PromptResponse(BaseModel): - id: int prompt_uid: str title: str description: Optional[str] = None diff --git a/backend/api/security.py b/backend/api/security.py index 7925284b3..801c98880 100644 --- a/backend/api/security.py +++ b/backend/api/security.py @@ -269,10 +269,12 @@ def _source_policy( workspace_id: str, writeback_enabled: bool, ) -> ResourcePolicy: + """Build a source policy that preserves tenant scope for every admin tier.""" delegated_user_ids: tuple[str, ...] = ( (auth_context.user_id,) if ( is_admin_role(auth_context.role) + and organization_id is not None and organization_id == auth_context.organization_id ) else () @@ -281,7 +283,14 @@ def _source_policy( return ResourcePolicy( owner_id=owner_id, organization_id=organization_id, - permitted_roles=("tenant_admin", "organization_admin", "group_admin", "member"), + permitted_roles=( + "system_admin", + "platform_admin", + "tenant_admin", + "organization_admin", + "group_admin", + "member", + ), permitted_group_ids=auth_context.group_ids, data_region=settings.DATA_REGION, required_consent_scopes=required_consent, diff --git a/backend/services/carddav_discovery.py b/backend/services/carddav_discovery.py index 70c742685..28d451846 100644 --- a/backend/services/carddav_discovery.py +++ b/backend/services/carddav_discovery.py @@ -26,10 +26,12 @@ import ipaddress import logging +import re import socket from dataclasses import dataclass from typing import Any, Awaitable, Callable -from urllib.parse import urljoin, urlsplit, urlunsplit +from unicodedata import category +from urllib.parse import unquote, urljoin, urlsplit, urlunsplit import httpx @@ -43,6 +45,11 @@ TxtResolver = Callable[[str], list[str]] HttpClientFactory = Callable[[], Any] +_MALFORMED_PERCENT_TRIPLET = re.compile(r"%(?![0-9A-Fa-f]{2})") +_PERCENT_TRIPLET = re.compile(r"%([0-9A-Fa-f]{2})") +_REMAINING_PERCENT_TRIPLET = re.compile(r"%[0-9A-Fa-f]{2}") +_RFC3986_RESERVED_CHARACTERS = frozenset(":/?#[]@!$&'()*+,;=") + @dataclass(frozen=True) class CarddavDiscoveryResult: @@ -216,26 +223,56 @@ def _default_txt_resolver(name: str) -> list[str]: return records +def _execution_path_preserving_reserved_escapes(path: str) -> str: + """Decode safe path octets while retaining encoded RFC 3986 reserved data.""" + + def protect_reserved(match: re.Match[str]) -> str: + octet = int(match.group(1), 16) + character = chr(octet) + if character not in _RFC3986_RESERVED_CHARACTERS: + return match.group(0) + # The context path itself must start with a structural slash. An + # encoded leading slash is therefore canonicalized, while later + # reserved escapes remain data exactly as the provider advertised. + if match.start() == 0 and character == "/": + return match.group(0) + return f"%25{match.group(1).upper()}" + + protected_path = _PERCENT_TRIPLET.sub(protect_reserved, path) + return unquote(protected_path, errors="strict") + + def _txt_context_path(records: list[str]) -> str | None: - """Extract and validate the RFC 6764 Section 6 TXT ``path`` hint.""" + """Validate one decode of an RFC 6764 TXT ``path`` and preserve wire identity.""" for record in records: for part in record.split(";"): key, _, value = part.strip().partition("=") if key.strip().lower() != "path": continue path = value.strip() + if _MALFORMED_PERCENT_TRIPLET.search(path): + continue + try: + decoded_path = unquote(path, errors="strict") + execution_path = _execution_path_preserving_reserved_escapes(path) + except UnicodeDecodeError: + continue + if _REMAINING_PERCENT_TRIPLET.search(decoded_path): + # A second decode would change the request target. Reject the + # ambiguous value instead of inventing a recursive decode count. + continue if ( - path.startswith("/") - and "://" not in path - and "\\" not in path - and "?" not in path - and "#" not in path + decoded_path.startswith("/") + and "://" not in decoded_path + and "\\" not in decoded_path + and "?" not in decoded_path + and "#" not in decoded_path and all( - segment not in {".", ".."} for segment in path.split("/") + segment not in {".", ".."} for segment in decoded_path.split("/") ) - and all(ord(ch) >= 32 and ord(ch) != 127 for ch in path) + and all(category(ch) != "Cc" for ch in decoded_path) ): - return path + return execution_path return None diff --git a/backend/tests/test_auth_oidc_admin_roles.py b/backend/tests/test_auth_oidc_admin_roles.py new file mode 100644 index 000000000..e36e3eb17 --- /dev/null +++ b/backend/tests/test_auth_oidc_admin_roles.py @@ -0,0 +1,92 @@ +"""Focused contracts for high-privilege OIDC session authority.""" + +import time + +import pytest +from fastapi import HTTPException + +from api import auth as auth_module +from core.config import settings + + +ADMIN_ROLES = ( + "system_admin", + "platform_admin", + "tenant_admin", + "organization_admin", +) + + +def _oidc_payload(role: str) -> dict[str, object]: + """Build a short-lived payload from the configured authoritative OIDC issuer.""" + return { + "iss": "https://login.example.test/realms/naruon", + "aud": "naruon-api", + "sub": "alice", + "role": role, + "org": "org-acme", + "groups": ["group-1"], + "workspace": "workspace-org-acme", + "exp": int(time.time()) + 300, + } + + +def _hmac_payload(role: str) -> dict[str, object]: + """Build compatibility-session metadata without granting membership authority.""" + return { + "ver": 1, + "iss": auth_module.SESSION_ISSUER, + "aud": auth_module.SESSION_AUDIENCE, + "sub": "alice", + "role": role, + "org": "org-acme", + "groups": ["group-1"], + "workspace": "workspace-org-acme", + "exp": int(time.time()) + 300, + } + + +@pytest.mark.parametrize("admin_role", ADMIN_ROLES) +def test_trusted_oidc_session_can_supply_admin_role(monkeypatch, admin_role: str) -> None: + """A configured JWKS-backed IdP remains usable for authorized administrators.""" + previous_issuer_url = settings.OIDC_ISSUER_URL + previous_client_id = settings.OIDC_CLIENT_ID + settings.OIDC_ISSUER_URL = "https://login.example.test/realms/naruon" + settings.OIDC_CLIENT_ID = "naruon-api" + payload = _oidc_payload(admin_role) + + monkeypatch.setattr(auth_module, "jwks_client", object()) + monkeypatch.setattr( + auth_module, + "_decode_cached_oidc_session_payload", + lambda _token: payload, + ) + + try: + verified_payload, verifier = auth_module._verify_signed_session_token( + "trusted-idp-token" + ) + context = auth_module._auth_context_from_session_payload( + verified_payload, verifier + ) + finally: + settings.OIDC_ISSUER_URL = previous_issuer_url + settings.OIDC_CLIENT_ID = previous_client_id + + assert verifier == "oidc" + assert context.role == admin_role + assert context.organization_id == "org-acme" + assert context.workspace_id == "workspace-org-acme" + assert context.session_verifier == "oidc" + + +@pytest.mark.parametrize("admin_role", ADMIN_ROLES) +def test_hmac_compatibility_session_cannot_supply_admin_role(admin_role: str) -> None: + """HMAC compatibility credentials never become authoritative admin membership.""" + with pytest.raises(HTTPException) as exc: + auth_module._auth_context_from_session_payload( + _hmac_payload(admin_role), "hmac" + ) + + assert exc.value.status_code == 401 + assert exc.value.detail == "Authentication required" diff --git a/backend/tests/test_carddav_discovery.py b/backend/tests/test_carddav_discovery.py index f8bbd2987..8656b6e6d 100644 --- a/backend/tests/test_carddav_discovery.py +++ b/backend/tests/test_carddav_discovery.py @@ -163,6 +163,35 @@ def txt_resolver(name): assert result.base_url == "https://dav.example.com/" +@pytest.mark.parametrize( + "txt_path", + [ + "/%2e%2e%2fescape", + "/%252e%252e%252fescape", + "/%5c..%5cescape", + "/%255c..%255cescape", + "/safe%0aheader", + ], +) +@pytest.mark.asyncio +async def test_encoded_unsafe_txt_path_is_ignored(txt_path): + response = FakeResponse(404) + + def resolver(name): + if name == "_carddavs._tcp.example.com": + return [("dav.example.com", 443)] + return [] + + result = await discover_carddav( + "example.com", + http_client_factory=_factory(response), + srv_resolver=resolver, + txt_resolver=lambda name: [f"path={txt_path}"], + ) + assert result is not None + assert result.base_url == "https://dav.example.com/" + + @pytest.mark.asyncio async def test_no_discovery_returns_none(): response = FakeResponse(404) diff --git a/backend/tests/test_carddav_encoded_path_canonicalization.py b/backend/tests/test_carddav_encoded_path_canonicalization.py new file mode 100644 index 000000000..9325fda89 --- /dev/null +++ b/backend/tests/test_carddav_encoded_path_canonicalization.py @@ -0,0 +1,59 @@ +"""Regression tests for canonical CardDAV TXT context-path execution.""" + +import pytest + +from services.carddav_discovery import _txt_context_path + + +def test_fully_encoded_leading_slash_is_canonicalized() -> None: + """Execute the same singly decoded representation that passed validation.""" + assert _txt_context_path(["path=%2Fsafe"]) == "/safe" + + +def test_percent_encoded_unicode_path_is_canonicalized() -> None: + """Preserve a safe Unicode path after one percent-decoding pass.""" + assert _txt_context_path(["path=/%EC%A3%BC%EC%86%8C%EB%A1%9D"]) == "/주소록" + + +@pytest.mark.parametrize( + ("txt_path", "expected_path"), + [ + ("/users/alice%2Fcalendar", "/users/alice%2Fcalendar"), + ("/collections/a%3Bb", "/collections/a%3Bb"), + ], +) +def test_encoded_reserved_path_characters_preserve_wire_identity( + txt_path: str, + expected_path: str, +) -> None: + """Validate reserved escapes without turning them into path delimiters.""" + assert _txt_context_path([f"path={txt_path}"]) == expected_path + + +@pytest.mark.parametrize( + "txt_path", + [ + "/literal%252Fsegment", + "/%252e%252e%252fescape", + "/safe%2525control", + ], +) +def test_nested_percent_encoding_is_rejected(txt_path: str) -> None: + """Reject values whose meaning would change under a second decode pass.""" + assert _txt_context_path([f"path={txt_path}"]) is None + + +@pytest.mark.parametrize("txt_path", ["/safe%", "/safe%2", "/safe%2G"]) +def test_malformed_percent_triplets_are_rejected(txt_path: str) -> None: + """Reject malformed URI percent encodings instead of forwarding ambiguity.""" + assert _txt_context_path([f"path={txt_path}"]) is None + + +def test_invalid_utf8_percent_octet_is_rejected() -> None: + """Reject invalid UTF-8 rather than accepting a replacement-character path.""" + assert _txt_context_path(["path=/safe%FF"]) is None + + +def test_encoded_literal_percent_is_preserved_after_one_decode() -> None: + """Allow a single encoded percent when it does not form another triplet.""" + assert _txt_context_path(["path=/discount-100%25"]) == "/discount-100%" diff --git a/backend/tests/test_carddav_unicode_controls.py b/backend/tests/test_carddav_unicode_controls.py new file mode 100644 index 000000000..dabea83fb --- /dev/null +++ b/backend/tests/test_carddav_unicode_controls.py @@ -0,0 +1,5 @@ +from services.carddav_discovery import _txt_context_path + + +def test_txt_context_path_rejects_unicode_c1_control(): + assert _txt_context_path(["path=/safe%C2%85header"]) is None diff --git a/backend/tests/test_prompt_response_naming_contract.py b/backend/tests/test_prompt_response_naming_contract.py new file mode 100644 index 000000000..7bb7afa96 --- /dev/null +++ b/backend/tests/test_prompt_response_naming_contract.py @@ -0,0 +1,47 @@ +"""Naming and public-identifier contract for prompt responses.""" + +from __future__ import annotations + +import datetime +from types import SimpleNamespace + +from api.prompts import PromptResponse + + +def _prompt_record() -> SimpleNamespace: + """Return an ORM-shaped prompt record that still contains its private row id.""" + now = datetime.datetime(2026, 9, 1, tzinfo=datetime.timezone.utc) + return SimpleNamespace( + id=17, + prompt_uid="prompt-example", + title="Example", + description=None, + content="Summarize {{email}}", + is_shared=False, + created_by="user-example", + created_at=now, + updated_at=now, + ) + + +def test_prompt_response_uses_only_opaque_public_identifier() -> None: + """Sequential database identity must not enter the owned API response model.""" + assert "prompt_uid" in PromptResponse.model_fields + assert "id" not in PromptResponse.model_fields + assert "prompt_record_id" not in PromptResponse.model_fields + + prompt_response = PromptResponse.model_validate(_prompt_record()) + serialized_response = prompt_response.model_dump() + + assert serialized_response["prompt_uid"] == "prompt-example" + assert "id" not in serialized_response + assert "prompt_record_id" not in serialized_response + + +def test_prompt_response_json_schema_does_not_advertise_sequential_database_id() -> None: + """FastAPI's response schema must advertise the opaque UID as the sole identifier.""" + response_properties = PromptResponse.model_json_schema()["properties"] + + assert "prompt_uid" in response_properties + assert "id" not in response_properties + assert "prompt_record_id" not in response_properties diff --git a/backend/tests/test_prompts_api.py b/backend/tests/test_prompts_api.py index 2480031f4..3bc388a0e 100644 --- a/backend/tests/test_prompts_api.py +++ b/backend/tests/test_prompts_api.py @@ -187,13 +187,22 @@ def test_prompt_crud(auth_client): data = resp.json() assert data["title"] == "Test Prompt" assert data["prompt_uid"].startswith("prompt_") + # Identity is the opaque prompt_uid; the sequential DB surrogate must never + # be exposed on the API surface (see CLAUDE.md "never expose sequential + # database ids"). + assert "id" not in data assert mock_session.items[0].organization_id == "org-acme" assert mock_session.items[0].workspace_id == "workspace-org-acme" # List resp = auth_client.get("/api/prompts") assert resp.status_code == 200 - assert len(resp.json()) == 1 + listed = resp.json() + assert len(listed) == 1 + assert all( + "id" not in item and item["prompt_uid"].startswith("prompt_") + for item in listed + ) def test_prompt_list_scopes_shared_prompts_to_current_workspace(auth_client): diff --git a/backend/tests/test_security_source_policy_admin_roles.py b/backend/tests/test_security_source_policy_admin_roles.py new file mode 100644 index 000000000..67b322f0b --- /dev/null +++ b/backend/tests/test_security_source_policy_admin_roles.py @@ -0,0 +1,77 @@ +"""Regression tests for system-level roles in source access policies.""" + +import pytest + +from api.auth import AuthContext +from api.security import _access_request, _source_policy +from services.access_policy import evaluate_access + + +@pytest.mark.parametrize("role", ["system_admin", "platform_admin"]) +def test_source_policy_allows_system_admin_roles_in_current_organization(role): + auth_context = AuthContext( + user_id="global-admin", + role=role, + organization_id="org-acme", + group_ids=(), + workspace_id="workspace-org-acme", + ) + policy = _source_policy( + auth_context, + owner_id="source-owner", + organization_id="org-acme", + workspace_id="workspace-org-acme", + writeback_enabled=False, + ) + + decision = evaluate_access(_access_request(auth_context), policy) + + assert decision.allowed is True + assert decision.reason == "allowed" + + +@pytest.mark.parametrize("role", ["system_admin", "platform_admin"]) +def test_source_policy_keeps_system_admin_roles_inside_current_organization(role): + auth_context = AuthContext( + user_id="global-admin", + role=role, + organization_id="org-acme", + group_ids=(), + workspace_id="workspace-org-acme", + ) + policy = _source_policy( + auth_context, + owner_id="source-owner", + organization_id="org-rival", + workspace_id="workspace-org-acme", + writeback_enabled=False, + ) + + decision = evaluate_access(_access_request(auth_context), policy) + + assert decision.allowed is False + assert decision.reason == "organization_denied" + + +@pytest.mark.parametrize("role", ["system_admin", "platform_admin"]) +def test_source_policy_does_not_delegate_orgless_legacy_sources(role): + """Missing organization identity must not become an implicit admin delegation.""" + auth_context = AuthContext( + user_id="global-admin", + role=role, + organization_id=None, + group_ids=(), + workspace_id="workspace-legacy", + ) + policy = _source_policy( + auth_context, + owner_id="source-owner", + organization_id=None, + workspace_id="workspace-legacy", + writeback_enabled=False, + ) + + decision = evaluate_access(_access_request(auth_context), policy) + + assert decision.allowed is False + assert decision.reason == "ownership_denied" diff --git a/docs/doctoring/carddav-txt-path-canonicalization.md b/docs/doctoring/carddav-txt-path-canonicalization.md new file mode 100644 index 000000000..bf5f11fbe --- /dev/null +++ b/docs/doctoring/carddav-txt-path-canonicalization.md @@ -0,0 +1,47 @@ +# CardDAV TXT path canonicalization + +## Scope + +Naruon consumes the optional `path` key advertised by a secure `_carddavs._tcp` TXT record during CardDAV discovery. The value becomes part of an outbound HTTPS request target, so validation must reject ambiguous encodings without changing the provider-advertised identity of reserved path data. + +## Decision + +The parser applies the following fail-closed contract: + +1. Reject malformed percent triplets before decoding. +2. Percent-decode the TXT value exactly once with strict UTF-8 handling for security validation. +3. Reject the value when a valid percent triplet remains after that validation pass, because a second decoder could observe a different request target. +4. Reject traversal segments, backslashes, query or fragment delimiters, absolute-URI syntax, and Unicode control characters in the decoded validation representation. +5. Normalize percent-encoded unreserved/non-ASCII text through the existing single-pass UTF-8 path contract, while preserving percent-encoded RFC 3986 reserved characters in the executed path. The sole structural exception is an encoded leading `/`, which is canonicalized to the required leading path delimiter. +6. Uppercase the hexadecimal digits of preserved reserved escapes so equivalent percent encodings have one wire representation. + +This replaces the previous arbitrary five-round recursive decoding budget. Recursive decoding changed legitimate literal-percent paths and left the security meaning dependent on a chosen iteration count. The current contract performs one security decode and rejects nested encodings, while retaining the distinction RFC 3986 makes between a reserved character and its percent-encoded octet. For example, `/users/alice%2Fcalendar` remains distinct from `/users/alice/calendar`, and `/collections/a%3Bb` remains distinct from `/collections/a;b` after validation. + +An encoded literal percent remains supported when its decoded form does not begin another percent triplet. Invalid UTF-8 is rejected rather than normalized through the Unicode replacement character. + +## Product boundary + +This decision protects CardDAV auto-discovery only. It does not grant authorization to arbitrary paths, weaken the existing HTTPS/global-address SSRF controls, or treat TXT records as trusted credentials. Provider account authorization and resource ownership remain separate checks. + +## Verification + +The focused regression suite covers: + +- a singly encoded leading slash; +- Korean UTF-8 path text; +- percent-encoded reserved `/` and `;` data whose wire identity must survive validation; +- nested encoded slash and traversal forms; +- nested encoded percent forms; +- incomplete and non-hex percent triplets; +- invalid UTF-8 octets; +- a safe encoded literal percent. + +The RED regression was added first at `43467ed10fb3ec6c3f2075a38acf9a934342ca02`: the previous unconditional `unquote` returned structural `/` and `;` characters for the two reserved-escape cases. The minimal production repair is `882c1dda08276d11bb346774dcc3119f64bc51b9`, which keeps the fully decoded representation for security checks and derives a separate execution representation that protects RFC 3986 reserved escapes. Hosted exact-head execution remains required before the repair is classified GREEN. + +## References + +Berners-Lee, T., Fielding, R., & Masinter, L. (2005). *Uniform resource identifier (URI): Generic syntax* (RFC 3986). RFC Editor. https://doi.org/10.17487/RFC3986 + +Daboo, C. (2013). *Locating services for calendaring extensions to WebDAV (CalDAV) and vCard extensions to WebDAV (CardDAV)* (RFC 6764). RFC Editor. https://doi.org/10.17487/RFC6764 + +MITRE. (2025). *CWE-174: Double decoding of the same data*. Common Weakness Enumeration. https://cwe.mitre.org/data/definitions/174.html diff --git a/docs/doctoring/prompt-response-semantic-identifiers.md b/docs/doctoring/prompt-response-semantic-identifiers.md new file mode 100644 index 000000000..754f33dec --- /dev/null +++ b/docs/doctoring/prompt-response-semantic-identifiers.md @@ -0,0 +1,40 @@ +# Prompt response semantic identifiers + +## Decision + +Naruon's Prompt Catalog bounded context already has an opaque public identifier, `prompt_uid`, and a separate sequential database row identity, `PromptTemplate.id`. The public `/api/prompts` list and create responses expose only `prompt_uid`; the sequential row identity is not part of `PromptResponse`. + +A naming-only approach that aliases the sequential database identity under a more descriptive internal name is insufficient because it still exports an unnecessary enumerable identifier. The canonical public contract therefore removes both bare `id` and any renamed row-id alias instead of merely recasing or relabeling them. + +| Previous public field | Current public field | Meaning | +| --- | --- | --- | +| `id` (sequential database row id) | removed | private persistence identity | +| `prompt_uid` | `prompt_uid` | opaque public prompt identity | + +## DDD, security, and compatibility boundary + +- **Bounded context:** Prompt Catalog. +- **Entity:** persisted prompt-template record. +- **Public identity:** `prompt_uid` is the sole prompt identifier in list/create response contracts. +- **Persistence identity:** `PromptTemplate.id` remains private to persistence and may still exist on ORM records; Pydantic `from_attributes=True` ignores it because it is not a response field. +- **Authorization invariant:** organization/workspace ownership filters on `list_prompts` and creation ownership assignments remain unchanged. Opaque identifiers are defense-in-depth and do not replace object-level authorization. +- **Public contract:** the redundant sequential `id` response property is intentionally absent. Consumers use the already-present `prompt_uid` for prompt identity. +- **Persistence:** unchanged. No database migration, backfill, index change, new lock, UPSERT change, partition change, or read/write split is introduced. + +OWASP API Security Top 10 API1:2023 notes that object identifiers, including sequential integers, are common BOLA attack inputs and recommends random, unpredictable record identifiers together with proper object-level authorization. Naruon already has the unpredictable `prompt_uid`, so retaining a second sequential public identifier has no buyer-visible product benefit and widens the identifier surface unnecessarily. + +## Verification contract + +`backend/tests/test_prompts_api.py` verifies that create/list responses omit `id` while returning opaque `prompt_uid`. `backend/tests/test_prompt_response_naming_contract.py` additionally validates an ORM-shaped record that still contains private `id=17` and requires both runtime serialization and generated JSON schema to omit `id` and `prompt_record_id` while retaining `prompt_uid`. Exact-head repository CI, security workflows, review threads, and branch protection remain authoritative merge evidence. + +## Research traceability + +Empirical software-engineering research supports treating identifier names as program-comprehension artifacts rather than cosmetic style. Feitelson et al. found that explicitly choosing the concepts represented in a name improved judged name quality and tended to produce names containing more concepts; later replication work corroborated that model and found that merely making names longer was not equivalent to selecting meaningful concepts. Here the stronger domain conclusion is that the public concept is already fully represented by `prompt_uid`; a second database-row identifier should not be renamed and exported when it is not part of the public domain language. + +### References + +Alpern, R., Lazer, I., Tzachor, I., Hakim, H., Weissbuch, S., & Feitelson, D. G. (2024). *Reproducing, extending, and analyzing naming experiments*. arXiv. https://doi.org/10.48550/arXiv.2402.10022 + +Feitelson, D. G., Mizrahi, A., Noy, N., Ben Shabat, A., Eliyahu, O., & Sheffer, R. (2022). How developers choose names. *IEEE Transactions on Software Engineering, 48*(1), 37–52. https://doi.org/10.1109/TSE.2020.2976920 + +OWASP Foundation. (2023). *API1:2023 Broken Object Level Authorization*. OWASP API Security Top 10. https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/