Skip to content

Commit

Permalink
Generate triggers to apply permissions in sqlite
Browse files Browse the repository at this point in the history
  • Loading branch information
magnetised committed Apr 16, 2024
1 parent 682f294 commit 3451e6b
Show file tree
Hide file tree
Showing 18 changed files with 3,430 additions and 992 deletions.
21 changes: 20 additions & 1 deletion clients/typescript/src/_generated/protocol/satellite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,14 @@ export interface SatPerms {
userId: string;
rules: SatPerms_Rules | undefined;
roles: SatPerms_Role[];
/**
* `triggers` is the sql code to install these permissions as triggers in
* the local db.
* The assumption is that the entire message is compressed before sending
* over the wire so just include the trigger sql directly rather than
* compress it separately.
*/
triggers: string;
}

export enum SatPerms_Privilege {
Expand Down Expand Up @@ -4312,7 +4320,7 @@ export const SatShapeDataEnd = {
messageTypeRegistry.set(SatShapeDataEnd.$type, SatShapeDataEnd);

function createBaseSatPerms(): SatPerms {
return { $type: "Electric.Satellite.SatPerms", id: Long.ZERO, userId: "", rules: undefined, roles: [] };
return { $type: "Electric.Satellite.SatPerms", id: Long.ZERO, userId: "", rules: undefined, roles: [], triggers: "" };
}

export const SatPerms = {
Expand All @@ -4331,6 +4339,9 @@ export const SatPerms = {
for (const v of message.roles) {
SatPerms_Role.encode(v!, writer.uint32(34).fork()).ldelim();
}
if (message.triggers !== "") {
writer.uint32(42).string(message.triggers);
}
return writer;
},

Expand Down Expand Up @@ -4369,6 +4380,13 @@ export const SatPerms = {

message.roles.push(SatPerms_Role.decode(reader, reader.uint32()));
continue;
case 5:
if (tag !== 42) {
break;
}

message.triggers = reader.string();
continue;
}
if ((tag & 7) === 4 || tag === 0) {
break;
Expand All @@ -4390,6 +4408,7 @@ export const SatPerms = {
? SatPerms_Rules.fromPartial(object.rules)
: undefined;
message.roles = object.roles?.map((e) => SatPerms_Role.fromPartial(e)) || [];
message.triggers = object.triggers ?? "";
return message;
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,28 @@ defmodule Electric.Postgres.Extension.SchemaLoader.Version do
end
end

def direct_fks(
%__MODULE__{} = version,
{_, _} = relation,
{target_schema, target_table} = target
) do
with {:ok, table_schema} = table(version, relation) do
table_schema.constraints
|> Stream.filter(&match?({:foreign, _}, &1.constraint))
|> Enum.find(fn %{constraint: {:foreign, %{pk_table: %{schema: sname, name: tname}}}} ->
sname == target_schema && tname == target_table
end)
|> case do
nil ->
{:error,
"no foreign key found from #{Electric.Utils.inspect_relation(relation)} to #{Electric.Utils.inspect_relation(target)}"}

%{constraint: {:foreign, %{fk_cols: fk_cols, pk_cols: pk_cols}}} ->
{:ok, fk_cols, pk_cols}
end
end
end

@spec fk_graph(t()) :: Graph.t()
def fk_graph(%__MODULE__{fk_graph: fk_graph}) do
fk_graph
Expand Down
136 changes: 107 additions & 29 deletions components/electric/lib/electric/postgres/schema/fk_graph.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,61 @@ defmodule Electric.Postgres.Schema.FkGraph do
key relations in a separate map of `%{relation() => %{relation() => [column_name()]}}`.
"""
alias Electric.Postgres.Schema.Proto
alias Electric.Postgres.Extension.SchemaLoader

defstruct [:graph, fks: %{}, pks: %{}]

@type relation() :: Electric.Postgres.relation()
@type name() :: Electric.Postgres.name()
@type fks() :: [name(), ...]
@type pks() :: [name(), ...]
@type join() ::
{:many_to_one, {relation(), fks()}, {relation(), pks()}}
| {:one_to_many, {relation(), pks()}, {relation(), fks()}}

@type t() :: %__MODULE__{
graph: Graph.t(),
fks: %{relation() => %{relation() => fks()}},
pks: %{relation() => pks()}
}
@type edge() :: {relation(), relation(), label: fks()} | {relation(), relation(), fks()}

defstruct [:graph, fks: %{}]
@spec for_schema(SchemaLoader.Version.t()) :: t()
def for_schema(%SchemaLoader.Version{schema: schema}) do
for_schema(schema)
end

def for_schema(%Proto.Schema{tables: tables}) do
tables
|> Stream.flat_map(fn %Proto.Table{constraints: constraints, name: name} ->
constraints
|> Stream.filter(&match?(%{constraint: {:foreign, _}}, &1))
|> Enum.map(fn %{constraint: {:foreign, fk}} ->
{{name.schema, name.name}, {fk.pk_table.schema, fk.pk_table.name}, fk.fk_cols}
fks =
tables
|> Enum.flat_map(fn %Proto.Table{constraints: constraints, name: name} ->
constraints
|> Stream.filter(&match?(%{constraint: {:foreign, _}}, &1))
|> Enum.map(fn %{constraint: {:foreign, fk}} ->
{{name.schema, name.name}, {fk.pk_table.schema, fk.pk_table.name}, fk.fk_cols}
end)
end)

pks =
tables
|> Stream.flat_map(fn %Proto.Table{constraints: constraints, name: name} ->
constraints
|> Stream.filter(&match?(%{constraint: {:primary, _}}, &1))
|> Enum.map(fn %{constraint: {:primary, pk}} ->
{{name.schema, name.name}, pk.keys}
end)
end)
end)
|> new()
|> Map.new()

new(fks, pks)
end

defp new_graph do
Graph.new(type: :undirected, vertex_identifier: & &1)
end

def new(edges) do
@spec new([edge()], %{relation() => pks()}) :: t()
def new(edges, pks) do
{graph, fks} =
edges
|> Enum.reduce({new_graph(), %{}}, fn edge, {graph, fks} ->
Expand All @@ -43,7 +78,7 @@ defmodule Electric.Postgres.Schema.FkGraph do
}
end)

%__MODULE__{graph: graph, fks: fks}
%__MODULE__{graph: graph, fks: fks, pks: pks}
end

defp normalise_edge({{_, _} = v1, {_, _} = v2, label: fk_columns}) when is_list(fk_columns) do
Expand All @@ -54,40 +89,83 @@ defmodule Electric.Postgres.Schema.FkGraph do
{v1, v2, fk_columns}
end

@doc """
Give the foreign keys on table `relation` that place it in the scope defined by `root`.
"""
@spec foreign_keys(t(), relation(), relation()) :: fks() | nil
# [VAX-1626] we don't support recursive relations
def foreign_keys(%__MODULE__{}, {_, _} = root, root) do
[]
nil
end

def foreign_keys(%__MODULE__{fks: fks} = fk_graph, {_, _} = root, {_, _} = relation) do
# we guard against looking for a fk ref to the same table above so relation_path/3 is always
# going to return a list of at least 2 items or nil if there is no route between the two
# going to return a list of at least 2 items or `nil` if there is no route between the two
# tables

case path(fk_graph, root, relation) do
[r1 | _] ->
case Map.get(fks, r1, nil) do
table_fks when is_map(table_fks) ->
# this gives us a list of the fks pointing out of this table
# we now need to find which of those live within the `root` scope
Enum.filter(table_fks, fn {fk_relation, _fk_cols} ->
is_list(path(fk_graph, root, fk_relation))
end)

_ ->
[]
end

nil ->
[]
with [r1 | _] <- path(fk_graph, root, relation),
table_fks when is_map(table_fks) <- Map.get(fks, r1, nil) do
# this gives us a list of the fks pointing out of this table
# we now need to find which of those live within the `root` scope
Enum.filter(table_fks, fn {fk_relation, _fk_cols} ->
is_list(path(fk_graph, root, fk_relation))
end)
end
end

@doc """
Get a relation path from the `target` table to the `root` table, defined by a foreign key
constraints between all tables in the path.
"""
@spec path(t(), root :: relation(), target :: relation()) :: [relation(), ...] | nil
def path(%__MODULE__{}, {_, _} = root, root) do
[root]
end

def path(%__MODULE__{graph: graph}, {_, _} = root, {_, _} = relation) do
Graph.get_shortest_path(graph, relation, root)
end

@doc """
Get the foreign key path information between the `root` table and the given `relation`.
Each entry in the path is either
- `{:many_to_one, {from_table, foreign_key_columns}, {to_table, primary_key_columns}}`, or
- `{:one_to_many, {from_table, primary_key_columns}, {to_table, foreign_key_columns}}`
depending on the relation between the two tables.
"""
@spec fk_path(t(), relation(), relation()) :: [join()] | nil
def fk_path(%__MODULE__{} = fk_graph, {_, _} = root, {_, _} = relation) do
with [_ | _] = path <- path(fk_graph, root, relation) do
path
|> Enum.chunk_every(2, 1, :discard)
|> Enum.map(fn [a, b] -> join(fk_graph, a, b) end)
end
end

@doc """
Given the two tables `source` and `target` describe the fk relation between them, either
`:many_to_one` or `:one_to_many`.
See `fk_path/3` above.
"""
@spec join(t(), relation(), relation()) :: join()
def join(%__MODULE__{fks: fks, pks: pks}, {_, _} = source, {_, _} = target) do
cond do
fks = get_in(fks, [source, target]) ->
{:many_to_one, {source, fks}, {target, Map.fetch!(pks, target)}}

fks = get_in(fks, [target, source]) ->
{:one_to_many, {source, Map.fetch!(pks, source)}, {target, fks}}
end
end

@doc """
Return the primary key columns for the given relation.
"""
@spec primary_keys(t(), relation()) :: {:ok, pks()} | :error
def primary_keys(%__MODULE__{pks: pks}, {_, _} = table) do
Map.fetch(pks, table)
end
end
11 changes: 10 additions & 1 deletion components/electric/lib/electric/satellite/permissions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,15 @@ defmodule Electric.Satellite.Permissions do
}
end

@doc """
Generate list of `#{Permissions.Role}` structs for all our currently assigned roles plus the
`Anyone` and `Authenticated` roles (if applicable).
"""
@spec assigned_roles(t()) :: [Role.t()]
def assigned_roles(perms) do
build_roles(perms.source.roles, perms.auth, perms.source.rules.assigns)
end

@doc """
Pass the transaction to the write buffer so it can reset itself when its pending writes have
completed the loop back from pg and are now in the underlying shape graph.
Expand Down Expand Up @@ -684,7 +693,7 @@ defmodule Electric.Satellite.Permissions do
end

{:error,
"user does not have permission to " <>
"permissions: user does not have permission to " <>
action <> Electric.Utils.inspect_relation(relation)}
end

Expand Down
Loading

0 comments on commit 3451e6b

Please sign in to comment.