Zum Inhalt springen

Views

Status: Entwurf · Spec-Kandidat: ja

Entscheidung OP-16: keine statischen Reporting-Views je Entitätstyp

JSONB→Spalten-Projektion ist nicht in der Datenbank als statische mdm.v_<key>-Views umgesetzt (Option C, 2026-04-29). Stattdessen:

  • Der Query-Layer (Drizzle / JOOQ o. ä.) generiert zur Laufzeit jsonb_to_record-Ausdrücke, die auf Basis der aktuellen DDM_ENTITY_TYPE_ATTRIBUTE-Metadaten typisierte Projektion erzeugen.
  • BI-Konsumenten greifen über die REST API zu — kein direkter DB-Zugriff für Reporting.
  • Vorteile: kein View-Drift bei Schemaänderungen, kein Migrations-Overhead, Typinformationen aus Metadaten sind direkt nutzbar.
  • Nachteil: BI-Tools, die SQL-Direktzugriff erwarten, benötigen einen zusätzlichen Adapter oder API-Layer.

Diese Entscheidung gilt für alle entitätstypspezifischen Projektionen. Operative/Auth-Views (RBAC, Stewardship-Inboxen) bleiben in mdm.*.

RBAC/ACL-Auflösungs-Views

Pflicht-Views zur Auflösung effektiver Permissions. Service-Layer muss diese Views nutzen, statt eine eigene Auflösung zu implementieren.

mdm.v_principal

Vereinheitlicht AA_APP_USER und AA_SERVICE_ACCOUNT zu einem Principal-Lookup.

CREATE OR REPLACE VIEW mdm.v_principal AS
SELECT
u.id AS principal_id,
'user'::mdm.principal_type AS principal_type,
u.external_id AS external_id,
u.email AS email,
u.display_name AS display_name,
u.is_active AS is_active,
u.deleted_at AS deleted_at
FROM mdm.AA_APP_USER u
UNION ALL
SELECT
s.id AS principal_id,
'service_account'::mdm.principal_type AS principal_type,
s.key AS external_id,
NULL::text AS email,
s.name AS display_name,
s.is_active AS is_active,
s.deleted_at AS deleted_at
FROM mdm.AA_SERVICE_ACCOUNT s;

mdm.v_principal_role_active

Aktive Rollenzuweisungen pro Principal, transitiv über Gruppen, gültig zu now().

CREATE OR REPLACE VIEW mdm.v_principal_role_active AS
-- Direkte Rollenzuweisungen
SELECT
pr.principal_type,
pr.principal_id,
pr.role_id,
pr.scope,
pr.scope_entity_type_id,
pr.scope_entity_id,
pr.scope_relation_type_id,
'direct'::text AS source
FROM mdm.AA_PRINCIPAL_ROLE pr
WHERE pr.deleted_at IS NULL
AND (pr.valid_from IS NULL OR pr.valid_from <= now())
AND (pr.valid_to IS NULL OR pr.valid_to > now())
UNION ALL
-- Über Gruppenmitgliedschaft geerbt
SELECT
m.member_principal_type AS principal_type,
m.member_principal_id AS principal_id,
pr.role_id,
pr.scope,
pr.scope_entity_type_id,
pr.scope_entity_id,
pr.scope_relation_type_id,
'group'::text AS source
FROM mdm.AA_PRINCIPAL_GROUP_MEMBER m
JOIN mdm.AA_PRINCIPAL_ROLE pr
ON pr.principal_type = 'group'
AND pr.principal_id = m.principal_group_id
WHERE m.deleted_at IS NULL
AND pr.deleted_at IS NULL
AND (pr.valid_from IS NULL OR pr.valid_from <= now())
AND (pr.valid_to IS NULL OR pr.valid_to > now());

mdm.v_effective_permission

Aufgelöste effektive Permissions je (principal, action, scope). RBAC + ACL kombiniert. deny gewinnt immer (unabhängig von Spezifität); Spezifität wird nur zwischen allow-Einträgen ausgewertet (z. B. für Attribut-Sichtbarkeit). Default ist nicht enthalten (= deny).

Die View ist bewusst eine normale View, kein MATERIALIZED VIEW: ACL/RBAC ändert sich häufig (Role-Assigns, neue ACL-Einträge), eine Materialisierung würde Refresh-Strategie + Invalidierungs-Trigger erzwingen. Falls Profiling (siehe Beobachtbarkeit) Bottleneck zeigt: Übergang zu einer expliziten Cache-Tabelle effective_permission_cache mit Trigger-basierter Invalidierung — nicht zu MATERIALIZED VIEW.

CREATE OR REPLACE VIEW mdm.v_effective_permission AS
WITH rbac AS (
SELECT
pra.principal_type,
pra.principal_id,
p.action,
p.scope,
coalesce(rp.scope_entity_type_id, pra.scope_entity_type_id) AS scope_entity_type_id,
coalesce(rp.scope_entity_id, pra.scope_entity_id) AS scope_entity_id,
coalesce(rp.scope_relation_type_id, pra.scope_relation_type_id) AS scope_relation_type_id,
NULL::text AS attribute_key,
rp.effect AS effect,
'rbac'::text AS source
FROM mdm.v_principal_role_active pra
JOIN mdm.AA_ROLE_PERMISSION rp
ON rp.role_id = pra.role_id
AND rp.deleted_at IS NULL
JOIN mdm.AA_PERMISSION p
ON p.id = rp.permission_id
),
acl AS (
SELECT
a.principal_type,
a.principal_id,
a.action,
a.scope,
a.scope_entity_type_id,
a.scope_entity_id,
a.scope_relation_type_id,
a.attribute_key,
a.effect,
'acl'::text AS source
FROM mdm.AA_ACL_ENTRY a
WHERE a.deleted_at IS NULL
AND (a.valid_from IS NULL OR a.valid_from <= now())
AND (a.valid_to IS NULL OR a.valid_to > now())
)
SELECT * FROM rbac
UNION ALL
SELECT * FROM acl;

Der Service-Layer wertet das Ergebnis pro Anfrage:

  1. Filtere auf Principal (direkt + Gruppen).
  2. Filtere auf action und Ziel-Scope.
  3. Wenn irgendein anwendbarer deny-Eintrag existiert (beliebige Spezifität): Zugriff verboten.
  4. Sonst, wenn mindestens ein anwendbarer allow-Eintrag existiert: erlaubt. Spezifität nur für Sub-Auswertung (z. B. attribute_key-Maskierung).
  5. Sonst: deny (Default).

Reporting-Views für Service-Layer-Regeln

Service-Layer-Regeln sind kein Schreib-Block, sondern müssen sichtbar sein. Die folgenden Views sind Stewardship-Inboxen.

mdm.v_relation_min_violations

Zeigt aktive (tenant, relation_type, from_entity)-Tripel, die unter min_to_per_from liegen (OP-13a).

CREATE OR REPLACE VIEW mdm.v_relation_min_violations AS
SELECT
rt.tenant_id,
rt.id AS relation_type_id,
rt.key AS relation_type_key,
e.id AS from_entity_id,
e.code AS from_entity_code,
rt.min_to_per_from,
count(er.id) AS active_count
FROM mdm.DDM_RELATION_TYPE rt
JOIN mdm.DDM_ENTITY e
ON e.tenant_id = rt.tenant_id
AND e.entity_type_id = rt.from_entity_type_id
AND e.deleted_at IS NULL
LEFT JOIN mdm.DDM_ENTITY_RELATION er
ON er.tenant_id = rt.tenant_id
AND er.relation_type_id = rt.id
AND er.from_entity_id = e.id
AND er.deleted_at IS NULL
WHERE rt.min_to_per_from > 0
AND rt.is_active = true
GROUP BY rt.tenant_id, rt.id, rt.key, e.id, e.code, rt.min_to_per_from
HAVING count(er.id) < rt.min_to_per_from;

mdm.v_relation_max_violations

Zeigt (tenant, relation_type, from_entity)-Tripel, die max_to_per_from überschreiten. Tritt nur auf, wenn der Service-Layer fehlerhaft ist oder ein Bulk-Import bewusst Re-Parenting fährt; ist ein Alarm.

CREATE OR REPLACE VIEW mdm.v_relation_max_violations AS
SELECT
er.tenant_id,
rt.id AS relation_type_id,
rt.key AS relation_type_key,
er.from_entity_id,
rt.max_to_per_from,
count(*) AS active_count
FROM mdm.DDM_ENTITY_RELATION er
JOIN mdm.DDM_RELATION_TYPE rt
ON rt.id = er.relation_type_id
WHERE er.deleted_at IS NULL
AND rt.max_to_per_from IS NOT NULL
GROUP BY er.tenant_id, rt.id, rt.key, er.from_entity_id, rt.max_to_per_from
HAVING count(*) > rt.max_to_per_from;

mdm.v_entity_attribute_drift

Listet aktive Entities, deren attributes-jsonb nicht zum aktuellen DDM_ENTITY_TYPE_ATTRIBUTE-Schema passen (OP-19, Lazy-Validate). Stewardship-Inbox für Schema-Evolution.

CREATE OR REPLACE VIEW mdm.v_entity_attribute_drift AS
SELECT
e.tenant_id,
e.id AS entity_id,
e.code,
et.key AS entity_type_key,
drift.kind, -- 'missing_required' | 'unknown_key' | 'wrong_type'
drift.attribute_key
FROM mdm.DDM_ENTITY e
JOIN mdm.DDM_ENTITY_TYPE et
ON et.id = e.entity_type_id
CROSS JOIN LATERAL mdm.fn_attribute_drift(e.id) drift
WHERE e.deleted_at IS NULL;

mdm.fn_attribute_drift(entity_id uuid) ist eine SQL-Funktion, die das attributes-jsonb gegen die aktive DDM_ENTITY_TYPE_ATTRIBUTE-Definition prüft und Drift-Zeilen zurückgibt. Wird mit OP-19-Spec ausgearbeitet.

mdm.v_index_drift

Listet aktive Attribute mit searchable=true oder sortable=true, deren Index-Lifecycle nicht im Zielzustand active ist (OP-17). Stewardship-Inbox: alles, was hier auftaucht, blockiert Filter/Sort über die UI/REST-API.

CREATE OR REPLACE VIEW mdm.v_index_drift AS
SELECT
et.tenant_id,
et.id AS entity_type_id,
et.key AS entity_type_key,
eta.id AS attribute_id,
eta.key AS attribute_key,
eta.searchable,
eta.sortable,
eta.data_type,
eta.index_status,
eta.index_built_at
FROM mdm.DDM_ENTITY_TYPE_ATTRIBUTE eta
JOIN mdm.DDM_ENTITY_TYPE et
ON et.id = eta.entity_type_id
WHERE eta.is_active = true
AND (eta.searchable = true OR eta.sortable = true)
AND (eta.index_status IS NULL OR eta.index_status <> 'active');

Soll-Ist-Logik:

  • Soll: index_status='active' für jedes aktive Attribut mit searchable=true OR sortable=true.
  • Ist: Status-Spalte; pending und building sind transient (Worker arbeitet), failed ist Stewardship-pflichtig, NULL zusammen mit searchable=true OR sortable=true ist ein Bug (Service hätte pending setzen müssen).

Für GIN-Catch-all-Drift (OP-17, is_external_source_target=true ohne Index) bleibt eine separate Detektion über pg_indexes Sache des Wartungs-Jobs cleanup_jobs — nicht als View, weil pg_indexes nicht in das Reporting-Modell gehört.

Hinweise

  • Operative Views (v_relation_min_violations, v_entity_attribute_drift, v_index_drift) filtern immer auf deleted_at IS NULL — sie sind Stewardship-Inboxen, keine History-Quellen.
  • Audit-History läuft über AA_AUDIT_LOG (OP-06: 10 Jahre Retention), nicht über Views.
  • Bei Bedarf an Materialized Views für Aggregations-Reports: OP-44 (Reporting-Layer) adressiert das — erst wenn konkrete BI-Anforderungen vorliegen.

Offen

  • Zugriffsmodell für BI-API (REST-Endpunkte, Aggregation-Layer, Authentifizierung für externe BI-Tools): OP-44.
  • Auflösung von enum-Werten zu Labels im Query-Layer (Join auf DDM_ENUM_VALUE dynamisch).

Verwandte Dokumente