-- ============================================================================= -- 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;