11defmodule Console.Schema.PolicyBinding do
22 use Piazza.Ecto.Schema
33 alias Console.Schema . { User , Group }
4+ alias Console.Repo
5+ import Ecto.Query
46
57 schema "policy_bindings" do
68 field :policy_id , :binary_id
@@ -10,6 +12,109 @@ defmodule Console.Schema.PolicyBinding do
1012 timestamps ( )
1113 end
1214
15+ @ doc """
16+ Fetches all tables and their policy-related columns from the database schema.
17+ Returns a map of table_name => [column_names] for uuid columns ending in '_policy_id' or 'bindings_id'.
18+ """
19+ def policy_columns do
20+ from ( c in "columns" ,
21+ prefix: "information_schema" ,
22+ where: like ( c . column_name , "%policy_id" ) or c . column_name == "bindings_id" ,
23+ where: c . table_schema == "public" ,
24+ where: c . table_name != "policy_bindings" ,
25+ where: c . data_type == "uuid" ,
26+ select: { c . table_name , c . column_name } ,
27+ order_by: [ c . table_name , c . column_name ]
28+ )
29+ |> Repo . all ( timeout: 30_000 )
30+ |> Enum . group_by ( fn { table , _col } -> table end , fn { _table , col } -> col end )
31+ end
32+
33+ @ doc """
34+ Returns IDs of policy bindings not referenced by any of the given tables.
35+ Uses a database-side subquery with LEFT JOIN to avoid loading all policy_ids into memory.
36+
37+ ## Parameters
38+ - `table_columns` - A map of table_name => [column_names] to check for references.
39+ If not provided, defaults to all policy columns discovered via `policy_columns/0`.
40+
41+ ## Examples
42+
43+ # Check against all tables with policy columns
44+ PolicyBinding.dangling_ids()
45+
46+ # Check against specific tables only
47+ PolicyBinding.dangling_ids(%{"clusters" => ["read_policy_id", "write_policy_id"]})
48+
49+ """
50+ def dangling_ids ( table_columns \\ nil ) do
51+ table_columns = table_columns || policy_columns ( )
52+
53+ case build_referenced_subquery ( table_columns ) do
54+ nil ->
55+ # No tables to check against, all bindings are considered "dangling"
56+ from ( pb in __MODULE__ , select: pb . id , order_by: [ asc: pb . id ] )
57+ |> Repo . all ( timeout: 300_000 )
58+
59+ referenced_subquery ->
60+ from ( pb in __MODULE__ ,
61+ left_join: r in subquery ( referenced_subquery ) ,
62+ on: pb . policy_id == r . policy_id ,
63+ where: is_nil ( r . policy_id ) ,
64+ select: pb . id ,
65+ order_by: [ asc: pb . id ]
66+ )
67+ |> Repo . all ( timeout: 300_000 )
68+ end
69+ end
70+
71+ @ doc """
72+ Builds a subquery that unions all referenced policy_ids from the given tables.
73+ Returns nil if there are no tables/columns to check.
74+ """
75+ def build_referenced_subquery ( table_columns ) when map_size ( table_columns ) == 0 , do: nil
76+
77+ def build_referenced_subquery ( table_columns ) do
78+ queries =
79+ table_columns
80+ |> Enum . flat_map ( fn { table , columns } ->
81+ Enum . map ( columns , fn col ->
82+ col_atom = String . to_atom ( col )
83+
84+ from ( t in table ,
85+ select: % { policy_id: field ( t , ^ col_atom ) } ,
86+ where: not is_nil ( field ( t , ^ col_atom ) )
87+ )
88+ end )
89+ end )
90+
91+ case queries do
92+ [ ] -> nil
93+ [ single ] -> single
94+ [ first | rest ] ->
95+ Enum . reduce ( rest , first , fn query , acc ->
96+ from ( q in acc , union_all: ^ query )
97+ end )
98+ end
99+ end
100+
101+ @ doc """
102+ Returns an Ecto query for dangling policy bindings.
103+
104+ ## Parameters
105+ - `query` - Base query to filter (defaults to PolicyBinding)
106+ - `table_columns` - Optional map of table_name => [column_names] to check against
107+
108+ """
109+ def dangling ( query \\ __MODULE__ , table_columns \\ nil ) do
110+ ids = dangling_ids ( table_columns )
111+ from ( p in query , where: p . id in ^ ids )
112+ end
113+
114+ def ordered ( query \\ __MODULE__ , order \\ [ asc: :id ] ) do
115+ from ( p in query , order_by: ^ order )
116+ end
117+
13118 @ valid ~w( user_id group_id policy_id) a
14119
15120 def changeset ( model , attrs \\ % { } ) do
0 commit comments