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 aktuellenDDM_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 ASSELECT 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_atFROM mdm.AA_APP_USER uUNION ALLSELECT 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_atFROM 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 RollenzuweisungenSELECT 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 sourceFROM mdm.AA_PRINCIPAL_ROLE prWHERE 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 geerbtSELECT 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 sourceFROM mdm.AA_PRINCIPAL_GROUP_MEMBER mJOIN mdm.AA_PRINCIPAL_ROLE pr ON pr.principal_type = 'group' AND pr.principal_id = m.principal_group_idWHERE 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 ASWITH 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 rbacUNION ALLSELECT * FROM acl;Der Service-Layer wertet das Ergebnis pro Anfrage:
- Filtere auf Principal (direkt + Gruppen).
- Filtere auf
actionund Ziel-Scope. - Wenn irgendein anwendbarer
deny-Eintrag existiert (beliebige Spezifität): Zugriff verboten. - Sonst, wenn mindestens ein anwendbarer
allow-Eintrag existiert: erlaubt. Spezifität nur für Sub-Auswertung (z. B.attribute_key-Maskierung). - 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 ASSELECT 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_countFROM mdm.DDM_RELATION_TYPE rtJOIN 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 NULLLEFT 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 NULLWHERE rt.min_to_per_from > 0 AND rt.is_active = trueGROUP BY rt.tenant_id, rt.id, rt.key, e.id, e.code, rt.min_to_per_fromHAVING 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 ASSELECT 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_countFROM mdm.DDM_ENTITY_RELATION erJOIN mdm.DDM_RELATION_TYPE rt ON rt.id = er.relation_type_idWHERE er.deleted_at IS NULL AND rt.max_to_per_from IS NOT NULLGROUP BY er.tenant_id, rt.id, rt.key, er.from_entity_id, rt.max_to_per_fromHAVING 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 ASSELECT 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_keyFROM mdm.DDM_ENTITY eJOIN mdm.DDM_ENTITY_TYPE et ON et.id = e.entity_type_idCROSS JOIN LATERAL mdm.fn_attribute_drift(e.id) driftWHERE 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 ASSELECT 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_atFROM mdm.DDM_ENTITY_TYPE_ATTRIBUTE etaJOIN mdm.DDM_ENTITY_TYPE et ON et.id = eta.entity_type_idWHERE 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 mitsearchable=true OR sortable=true. - Ist: Status-Spalte;
pendingundbuildingsind transient (Worker arbeitet),failedist Stewardship-pflichtig,NULLzusammen mitsearchable=true OR sortable=trueist ein Bug (Service hättependingsetzen 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 aufdeleted_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 aufDDM_ENUM_VALUEdynamisch).