prole/knoe-db/schema/ekosystem_objects.sql
chrisfu cf33342500 feat(prole): bootstrap knoe-auth on k3s; tenant onboarding; cluster stabilisation
knoe-auth (prole.org k3s):
- Fix CNPG manifest drift: remove spec.backup.pluginConfiguration (CNPG 1.28 only),
  switch spec.certificates from serverTLSSecret to serverAltDNSNames
- Apply knoe-auth Round 1 schema + GRANTs manually (postInitSQL had never run on live cluster)
- Fix OIDC signing key generator: base64(DER) not base64(PEM) — OidcTokenService
  does Base64.decode() → PKCS8EncodedKeySpec which requires raw DER bytes
- Add OIDC controllers: authorize, token, userinfo, jwks, discovery
- Add prole Spring profile: cookieDomain, emailDomain, Kerberos config
- Add secret example templates: knoe-db-user, knoe-auth-oidc-signing, knoe-auth-google-prole
- Kong configmap: scope knoe-auth route to /auth prefix only

Tenant onboarding:
- Add etc/onboard_tenant.sh: provision/apply/rotate/status workflow backed by 1Password
  vaults; types: 'enterprise' (own Kerberos + domain) and 'tenant' (hosted, initContainer KDC)
- Provision 'Knoe Tenant - prole.org' vault; apply all 7 k8s secrets to knoe-system
- init_knoe_auth.sh: add explicit GRANT + ALTER DEFAULT PRIVILEGES for knoe role

Cluster stabilisation:
- gitea: roll back 14-day stuck rollout (RWO PVC + maxSurge=100% deadlock);
  patch deployment strategy to Recreate
- supabase: create supabase_admin role, _supabase db, _analytics schema, _realtime schema
  in CNPG — analytics and realtime had never connected since Helm install day 1
- knoe-db barman ObjectStore: add GCS-backed objectstore manifest + scheduled backup

Infrastructure:
- gandalf host_vars: k3s registry config
- pi host_vars: clean up stale entries
- knoe-db schemas: ekosystem.sql, ekosystem_objects.sql
- init_prole_app.sql: prole app DB initialisation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 00:50:37 -07:00

279 lines
11 KiB
PL/PgSQL

-- =============================================================================
-- knoe.ekosystem_objects — Modular object kind registry, naming, and
-- cross-ekosystem sharing / grant model
--
-- Depends on: ekosystem.sql (knoe.tenants, knoe.ekosystem_decode)
--
-- Object naming convention:
-- {prefix}_{uuid} — standalone objects (project, user, embedding)
-- {prefix}_{uuid}_{name} — compound objects (table, index, view)
--
-- All prefixes are {letter}_ so ekosystem_tenant() works uniformly:
-- substring(object_name FROM 3 FOR 16) always extracts the UUID.
--
-- Cross-ekosystem sharing:
-- UUIDs embed the originating tenant_id in bits — ownership is always
-- decodable without a registry join, so imported objects cannot collide
-- even when mixed across ekosystems. knoe.cross_grants records the
-- explicit sharing approvals that LDAP/Samba AD will eventually enforce.
-- =============================================================================
-- ---------------------------------------------------------------------------
-- knoe.object_kinds — Modular kind registry
-- Add new kinds here; ekosystem_object() picks them up automatically.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS knoe.object_kinds (
kind text PRIMARY KEY,
prefix char(1) NOT NULL UNIQUE CHECK (prefix ~ '^[a-z]$'),
description text NOT NULL
);
INSERT INTO knoe.object_kinds (kind, prefix, description) VALUES
('project', 'p', 'Project namespace — schema or resource root'),
('user', 'u', 'Service account / PostgreSQL role'),
('embedding', 'e', 'Vector embedding store'),
('table', 't', 'Data table'),
('index', 'x', 'Index or search structure'),
('view', 'v', 'Logical view or materialised view'),
('sequence', 's', 'Auto-increment sequence'),
('function', 'f', 'Stored function or procedure')
ON CONFLICT DO NOTHING;
GRANT SELECT ON TABLE knoe.object_kinds TO knoe;
-- ---------------------------------------------------------------------------
-- knoe.ekosystem_object(uuid, kind) → '{prefix}_{uuid}'
--
-- Returns the canonical identifier for any typed object.
-- Unknown kind raises — register it in knoe.object_kinds first.
--
-- Compound names (tables, indexes):
-- knoe.ekosystem_object(uuid, 'table') || '_accounts'
-- → 't_3k9mxqt2f8vn0r7b_accounts'
--
-- The 2-char prefix is the same width for every kind, so
-- ekosystem_tenant() always finds the UUID at position 3.
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION knoe.ekosystem_object(p_uuid text, p_kind text)
RETURNS text
LANGUAGE plpgsql STABLE
AS $$
DECLARE
v_prefix char(1);
BEGIN
SELECT prefix INTO v_prefix FROM knoe.object_kinds WHERE kind = p_kind;
IF NOT FOUND THEN
RAISE EXCEPTION
'Unknown object kind: %. Register it in knoe.object_kinds first.', p_kind;
END IF;
RETURN v_prefix || '_' || p_uuid;
END;
$$;
-- ---------------------------------------------------------------------------
-- knoe.ekosystem_kind(object_name) → kind
--
-- Reverse lookup: given any prefixed object name, return its kind.
-- Returns NULL if the prefix is unregistered (not an error — enables routing
-- filters that skip non-ekosystem objects).
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION knoe.ekosystem_kind(p_object_name text)
RETURNS text
LANGUAGE sql STABLE
AS $$
SELECT kind
FROM knoe.object_kinds
WHERE prefix = substring(p_object_name FROM 1 FOR 1);
$$;
-- ---------------------------------------------------------------------------
-- knoe.cross_grants — Cross-ekosystem sharing policy
--
-- Records that grantor_tenant has shared an object with grantee_tenant.
-- This table is the policy source of truth; actual PG GRANT statements
-- and future LDAP/Samba AD group memberships are derived from it.
--
-- Ownership is always decodable from the UUID itself via ekosystem_decode(),
-- so this table only needs to record the *exceptions* (sharing across
-- tenant boundaries). Within a single ekosystem, use standard PG GRANT.
--
-- privileges: PostgreSQL privilege names, e.g. '{SELECT}', '{SELECT,INSERT}'
-- expires_at: NULL = permanent until explicitly revoked
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS knoe.cross_grants (
id text PRIMARY KEY
DEFAULT knoe.ekosystem_id(0, 0),
object_uuid text NOT NULL,
object_kind text NOT NULL REFERENCES knoe.object_kinds(kind),
grantor_tenant smallint NOT NULL REFERENCES knoe.tenants(tenant_id),
grantee_tenant smallint NOT NULL REFERENCES knoe.tenants(tenant_id),
privileges text[] NOT NULL CHECK (array_length(privileges, 1) > 0),
granted_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz,
CONSTRAINT cross_grants_no_self
CHECK (grantor_tenant != grantee_tenant),
CONSTRAINT cross_grants_unique
UNIQUE (object_uuid, object_kind, grantee_tenant)
);
CREATE INDEX IF NOT EXISTS cross_grants_grantee_idx
ON knoe.cross_grants (grantee_tenant, object_kind);
CREATE INDEX IF NOT EXISTS cross_grants_object_idx
ON knoe.cross_grants (object_uuid);
GRANT SELECT, INSERT, DELETE ON TABLE knoe.cross_grants TO knoe;
-- ---------------------------------------------------------------------------
-- knoe.grant_object(object_uuid, kind, grantee_tenant, privileges, expires_at)
--
-- Record that the owning tenant shares an object with another ekosystem.
-- Validates that the caller's tenant actually owns the object (via UUID bits).
-- On conflict: refreshes privileges and expiry (upsert semantics).
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION knoe.grant_object(
p_object_uuid text,
p_kind text,
p_grantee_tenant smallint,
p_privileges text[] DEFAULT '{SELECT}',
p_expires_at timestamptz DEFAULT NULL
) RETURNS knoe.cross_grants
LANGUAGE plpgsql
AS $$
DECLARE
v_grantor smallint;
v_row knoe.cross_grants;
BEGIN
-- Derive owner from UUID bits — no registry join needed
SELECT tenant_id INTO v_grantor FROM knoe.ekosystem_decode(p_object_uuid);
IF NOT EXISTS (SELECT 1 FROM knoe.tenants WHERE tenant_id = p_grantee_tenant) THEN
RAISE EXCEPTION 'Grantee tenant % is not a registered ekosystem', p_grantee_tenant;
END IF;
IF v_grantor = p_grantee_tenant THEN
RAISE EXCEPTION
'Grant target is the object owner (tenant %). Use standard PG GRANT within an ekosystem.',
v_grantor;
END IF;
INSERT INTO knoe.cross_grants
(object_uuid, object_kind, grantor_tenant, grantee_tenant, privileges, expires_at)
VALUES
(p_object_uuid, p_kind, v_grantor, p_grantee_tenant, p_privileges, p_expires_at)
ON CONFLICT (object_uuid, object_kind, grantee_tenant) DO UPDATE
SET privileges = EXCLUDED.privileges,
expires_at = EXCLUDED.expires_at,
granted_at = now()
RETURNING * INTO v_row;
RETURN v_row;
END;
$$;
-- ---------------------------------------------------------------------------
-- knoe.revoke_object(object_uuid, kind, grantee_tenant)
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION knoe.revoke_object(
p_object_uuid text,
p_kind text,
p_grantee_tenant smallint
) RETURNS boolean
LANGUAGE plpgsql
AS $$
BEGIN
DELETE FROM knoe.cross_grants
WHERE object_uuid = p_object_uuid
AND object_kind = p_kind
AND grantee_tenant = p_grantee_tenant;
RETURN FOUND;
END;
$$;
-- ---------------------------------------------------------------------------
-- knoe.can_access(object_uuid, kind, tenant_id) → boolean
--
-- True when:
-- (a) the tenant owns the object (tenant_id embedded in UUID), OR
-- (b) a non-expired cross-grant exists, OR
-- (c) tenant 0 (knoe-db root) — root has universal read access
--
-- STABLE: safe in WHERE clauses and on replicas.
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION knoe.can_access(
p_object_uuid text,
p_kind text,
p_tenant_id smallint
) RETURNS boolean
LANGUAGE sql STABLE
AS $$
SELECT
p_tenant_id = 0 -- root authority
OR
(SELECT tenant_id
FROM knoe.ekosystem_decode(p_object_uuid)) = p_tenant_id -- owner
OR
EXISTS (
SELECT 1 FROM knoe.cross_grants
WHERE object_uuid = p_object_uuid
AND object_kind = p_kind
AND grantee_tenant = p_tenant_id
AND (expires_at IS NULL OR expires_at > now())
);
$$;
-- ---------------------------------------------------------------------------
-- knoe.my_grants(tenant_id) → active cross-grants visible to this ekosystem
-- Includes both grants received and grants issued by this tenant.
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION knoe.my_grants(p_tenant_id smallint)
RETURNS TABLE(
direction text, -- 'received' | 'issued'
object_uuid text,
object_kind text,
object_name text, -- prefixed identifier
other_tenant smallint,
privileges text[],
granted_at timestamptz,
expires_at timestamptz
)
LANGUAGE sql STABLE
AS $$
SELECT 'received'::text,
g.object_uuid,
g.object_kind,
knoe.ekosystem_object(g.object_uuid, g.object_kind),
g.grantor_tenant,
g.privileges,
g.granted_at,
g.expires_at
FROM knoe.cross_grants g
WHERE g.grantee_tenant = p_tenant_id
AND (g.expires_at IS NULL OR g.expires_at > now())
UNION ALL
SELECT 'issued'::text,
g.object_uuid,
g.object_kind,
knoe.ekosystem_object(g.object_uuid, g.object_kind),
g.grantee_tenant,
g.privileges,
g.granted_at,
g.expires_at
FROM knoe.cross_grants g
WHERE g.grantor_tenant = p_tenant_id
AND (g.expires_at IS NULL OR g.expires_at > now());
$$;
-- ---------------------------------------------------------------------------
-- Grants (mirrors existing knoe schema pattern)
-- ---------------------------------------------------------------------------
GRANT EXECUTE ON FUNCTION knoe.ekosystem_object(text, text) TO knoe;
GRANT EXECUTE ON FUNCTION knoe.ekosystem_kind(text) TO knoe;
GRANT EXECUTE ON FUNCTION knoe.grant_object(text, text, smallint, text[], timestamptz)
TO knoe;
GRANT EXECUTE ON FUNCTION knoe.revoke_object(text, text, smallint) TO knoe;
GRANT EXECUTE ON FUNCTION knoe.can_access(text, text, smallint) TO knoe;
GRANT EXECUTE ON FUNCTION knoe.my_grants(smallint) TO knoe;