Files
windmill/backend/windmill-api-workspaces/src/datatable_acl.rs
T
Diego Imbert 28b31a437e feat(datatables): gate role and ACL changes on the enterprise edition
Both planners now live in windmill-ee-private, reached through a dispatcher
that refuses in the open-source build, and an enterprise binary additionally
checks for an active license before planning anything. Reading a data table's
roles, its usable roles and a schema's owner and grants stays open, so an
instance that lapses can still see what it has; every mutation is refused.

The UI stops offering what the server would refuse: no Roles entry on a data
table, and no Permissions entry on a schema or table, without a license.
2026-09-01 04:32:52 +02:00

817 lines
29 KiB
Rust

/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2022
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
//! Ownership and grants on the objects of a permissioned data table.
//!
//! [`datatable_permissions`](crate::datatable_permissions) decides which
//! Postgres roles exist; this decides what they may touch. Both speak in
//! Windmill role names — `admin`, `analyst` — and translate to the generated
//! Postgres roles here, so a caller never has to know one.
//!
//! Everything is expressed against an [`AclTarget`]. Only schemas are reachable
//! from the UI today; tables carry the same shape so the same plan/apply path
//! serves them.
use axum::{
extract::{Extension, Path, Query},
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use windmill_api_auth::ApiAuthed;
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::error::{pg_error_message, Error, JsonResult, Result};
use windmill_common::workspaces::{
can_use_datatable_role, get_datatable_resource_from_db,
get_datatable_resource_from_db_unchecked, DatatableAccess, ADMIN_DATATABLE_ROLE,
};
use windmill_common::{PgDatabase, DB};
use crate::datatable_permissions::{connect_as_admin, read_datatable};
pub(crate) fn routes() -> Router {
Router::new()
.route("/datatable_acl/{datatable_name}", get(get_datatable_acl))
.route(
"/datatable_acl/{datatable_name}/plan",
post(plan_datatable_acl),
)
.route(
"/datatable_acl/{datatable_name}/apply",
post(apply_datatable_acl),
)
}
/// What a read or a change is about. `Table` is unused by the UI so far and is
/// here because the SQL only differs in the object it names.
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AclTarget {
/// The data table's own database — where the privilege to create schemas
/// lives.
Database,
Schema {
schema: String,
},
Table {
schema: String,
table: String,
},
}
impl AclTarget {
/// What it is called in a message.
pub(crate) fn label(&self, dbname: &str) -> String {
match self {
AclTarget::Database => dbname.to_string(),
AclTarget::Schema { schema } => schema.clone(),
AclTarget::Table { schema, table } => format!("{schema}.{table}"),
}
}
}
#[derive(Deserialize, Debug)]
pub struct AclTargetQuery {
kind: String,
schema: Option<String>,
table: Option<String>,
/// The role to read as, when it is not the caller's default one.
role: Option<String>,
}
impl TryFrom<AclTargetQuery> for AclTarget {
type Error = Error;
fn try_from(q: AclTargetQuery) -> Result<Self> {
match (q.kind.as_str(), q.schema.clone(), q.table.clone()) {
("database", _, _) => Ok(AclTarget::Database),
("schema", Some(schema), _) => Ok(AclTarget::Schema { schema }),
("table", Some(schema), Some(table)) => Ok(AclTarget::Table { schema, table }),
("schema" | "table", None, _) => {
Err(Error::BadRequest("This target needs a schema".to_string()))
}
("table", _, None) => Err(Error::BadRequest(
"A table target needs a table".to_string(),
)),
(kind, _, _) => Err(Error::BadRequest(format!("Unknown ACL target '{kind}'"))),
}
}
}
/// Where a set of privileges applies, relative to the target.
///
/// `Future` covers what does not exist yet: those become `ALTER DEFAULT
/// PRIVILEGES`, which only binds objects created by the roles it names.
#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum GrantScope {
/// The target itself — the schema, or the table.
Target,
AllTables,
AllSequences,
AllFunctions,
FutureTables,
FutureSequences,
FutureFunctions,
}
/// A change to plan. One at a time: each is confirmed against its own SQL.
#[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AclChange {
/// Hand the target — and everything already in it — to another role.
SetOwner {
role: String,
},
Grant {
role: String,
privileges: Vec<String>,
scope: GrantScope,
},
Revoke {
role: String,
privileges: Vec<String>,
scope: GrantScope,
/// Objects inside the target, empty for the target itself. `ON ALL
/// TABLES` grants read back per object, so they are revoked per object —
/// and the same privileges on several of them are revoked together.
#[serde(default)]
objects: Vec<AclObject>,
},
}
#[derive(Deserialize, Debug)]
pub struct AclChangeRequest {
pub target: AclTarget,
pub change: AclChange,
/// The role to act as, when it is not the caller's default one.
#[serde(default)]
pub role: Option<String>,
}
/// An object inside a schema, named the way `REVOKE ... ON <keyword>` needs it.
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
pub struct AclObject {
pub name: String,
/// `TABLE`, `SEQUENCE`, ... — what the object is, since the keyword differs.
pub kind: String,
/// A routine is identified by its argument types, not by its name: two
/// `f` in one schema are two objects. Absent for everything else.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub args: Option<String>,
}
/// A grant as the database has it, in Windmill's vocabulary where it can be.
#[derive(Serialize, Debug, PartialEq)]
pub struct AclGrant {
/// Windmill role name when the grantee is one of the data table's roles,
/// else the raw Postgres role (`PUBLIC` included).
pub grantee: String,
pub privileges: Vec<String>,
/// `None` for the target itself, else the object inside it.
#[serde(skip_serializing_if = "Option::is_none")]
pub object: Option<AclObject>,
/// `TABLES` / `SEQUENCES` / `FUNCTIONS` when this is a default privilege,
/// which applies to objects that do not exist yet.
#[serde(skip_serializing_if = "Option::is_none")]
pub future: Option<String>,
}
#[derive(Serialize, Debug)]
pub struct DatatableAclInfo {
/// Windmill role name when the owner is one of the data table's roles, else
/// the raw Postgres role.
pub owner: String,
/// The data table's roles, in the order the config has them. All of them:
/// the list is not private, only what each may reach.
pub roles: Vec<String>,
/// The subset the caller may themselves run as. Handing an object to a role
/// outside this list gives it away.
pub usable_roles: Vec<String>,
/// Whether the role this connection is on may change the target at all —
/// Postgres asks for membership of the owning role.
pub can_manage: bool,
/// The Windmill role this connection is on.
pub current_role: String,
/// Whether the server is Postgres 17 or later, which added the `MAINTAIN`
/// table privilege.
pub supports_maintain: bool,
/// The database the target lives in, which no target carries itself.
pub dbname: String,
pub grants: Vec<AclGrant>,
}
#[derive(Serialize, Debug)]
pub struct AclPlan {
pub statements: Vec<String>,
pub warnings: Vec<String>,
}
/// The data table's own connection, which is what `admin` resolves to. Read from
/// the resource rather than from a connection: the caller may well not be able to
/// open one as that role.
async fn admin_pg_role(db: &DB, w_id: &str, datatable_name: &str) -> Result<String> {
let resource = get_datatable_resource_from_db_unchecked(db, w_id, datatable_name).await?;
let pg: PgDatabase = serde_json::from_value(resource)
.map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {e}")))?;
pg.user
.ok_or_else(|| Error::internal_err("The data table's connection names no user".to_string()))
}
/// What the caller reaches the data table as. Postgres is what decides whether
/// that role may change an owner or hand out a privilege, so the connection is
/// theirs — not the data table's admin one.
struct CallerConnection {
dbname: String,
/// The Postgres role this connection authenticated as.
current_user: String,
}
async fn connect_as_caller(
db: &DB,
authed: &ApiAuthed,
w_id: &str,
datatable_name: &str,
role: Option<&str>,
) -> Result<(tokio_postgres::Client, CallerConnection)> {
let resource = get_datatable_resource_from_db(
db,
w_id,
datatable_name,
role,
DatatableAccess::Authed(authed.to_authed_ref()),
)
.await?;
let pg_db: PgDatabase = serde_json::from_value(resource)
.map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {e}")))?;
let dbname = pg_db.dbname.clone();
let (client, connection) = pg_db.connect(Some(db)).await?;
tokio::spawn(async move {
if let Err(e) = connection.await {
tracing::error!("Datatable ACL connection error: {}", e);
}
});
let current_user: String = client
.query_one("SELECT current_user", &[])
.await
.map_err(|e| {
Error::internal_err(format!(
"Failed to read the connection identity: {}",
pg_error_message(&e)
))
})?
.get(0);
Ok((client, CallerConnection { dbname, current_user }))
}
/// Whether the connection's role is a member of the target's owner, which is
/// what "may change its access" means here.
async fn can_manage_target(client: &tokio_postgres::Client, target: &AclTarget) -> Result<bool> {
let row = match target {
AclTarget::Database => client
.query_opt(
"SELECT pg_has_role(datdba, 'USAGE') FROM pg_database WHERE datname = current_database()",
&[],
)
.await,
AclTarget::Schema { schema } => client
.query_opt(
"SELECT pg_has_role(nspowner, 'USAGE') FROM pg_namespace WHERE nspname = $1",
&[schema],
)
.await,
AclTarget::Table { schema, table } => client
.query_opt(
"SELECT pg_has_role(c.relowner, 'USAGE')
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = $1 AND c.relname = $2",
&[schema, table],
)
.await,
}
.map_err(|e| {
Error::internal_err(format!(
"Failed to read ownership: {}",
pg_error_message(&e)
))
})?;
Ok(row.map(|r| r.get::<_, bool>(0)).unwrap_or(false))
}
/// Windmill role name -> Postgres role name, for the roles of one data table.
///
/// `admin` maps to whatever the data table's own connection is, which is not
/// stored in the config: it is read off the connection.
async fn role_map(
db: &DB,
w_id: &str,
datatable_name: &str,
admin_pg_role: &str,
) -> Result<BTreeMap<String, String>> {
let datatable = read_datatable(db, w_id, datatable_name).await?;
let mut map = BTreeMap::new();
map.insert(ADMIN_DATATABLE_ROLE.to_string(), admin_pg_role.to_string());
if let Some(permissions) = datatable.permissions.filter(|p| p.enabled) {
for (name, role) in permissions.roles {
if let Some(pg_rolename) = role.pg_rolename {
map.insert(name, pg_rolename);
}
}
}
Ok(map)
}
fn pg_role_of(roles: &BTreeMap<String, String>, role: &str) -> Result<String> {
roles
.get(role)
.cloned()
.ok_or_else(|| Error::BadRequest(format!("Unknown role '{role}'")))
}
/// Read back a Postgres role as the Windmill role it belongs to, so the UI never
/// has to show a generated name.
fn windmill_role_of(roles: &BTreeMap<String, String>, pg_role: &str) -> String {
roles
.iter()
.find(|(_, pg)| pg.as_str() == pg_role)
.map(|(name, _)| name.clone())
.unwrap_or_else(|| pg_role.to_string())
}
/// An object whose ownership follows the schema's.
#[derive(Debug, PartialEq)]
pub(crate) struct OwnedObject {
pub(crate) name: String,
/// The keyword `ALTER ... OWNER TO` takes for this kind of object.
pub(crate) keyword: &'static str,
/// Identity arguments of a routine, which is what tells two of the same
/// name apart. `None` for a relation.
pub(crate) args: Option<String>,
}
fn keyword_of_relkind(relkind: i8) -> Option<&'static str> {
match relkind as u8 as char {
'r' | 'p' => Some("TABLE"),
'v' => Some("VIEW"),
'm' => Some("MATERIALIZED VIEW"),
'S' => Some("SEQUENCE"),
'f' => Some("FOREIGN TABLE"),
// Indexes and TOAST tables follow their table; composite types are not
// reachable through ALTER TABLE ... OWNER TO.
_ => None,
}
}
async fn read_owned_objects(
client: &tokio_postgres::Client,
schema: &str,
) -> Result<Vec<OwnedObject>> {
let rows = client
.query(
"SELECT c.relname, c.relkind
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = $1 AND c.relkind = ANY(ARRAY['r','p','v','m','S','f']::\"char\"[])
ORDER BY c.relname",
&[&schema],
)
.await
.map_err(|e| {
Error::internal_err(format!(
"Failed to list the objects of schema '{schema}': {}",
pg_error_message(&e)
))
})?;
let mut objects: Vec<OwnedObject> = rows
.into_iter()
.filter_map(|row| {
keyword_of_relkind(row.get::<_, i8>(1)).map(|keyword| OwnedObject {
name: row.get(0),
keyword,
args: None,
})
})
.collect();
// Routines live in `pg_proc`, not `pg_class`, and would keep the previous
// owner while the schema they are in changes hands. `ALTER ROUTINE` covers
// functions, procedures and aggregates alike.
let routines = client
.query(
"SELECT p.proname, pg_get_function_identity_arguments(p.oid)
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = $1
ORDER BY p.proname",
&[&schema],
)
.await
.map_err(|e| {
Error::internal_err(format!(
"Failed to list the routines of schema '{schema}': {}",
pg_error_message(&e)
))
})?;
objects.extend(routines.into_iter().map(|row| OwnedObject {
name: row.get(0),
keyword: "ROUTINE",
args: Some(row.get(1)),
}));
Ok(objects)
}
async fn get_datatable_acl(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, datatable_name)): Path<(String, String)>,
Query(query): Query<AclTargetQuery>,
) -> JsonResult<DatatableAclInfo> {
let role = query.role.clone();
let target: AclTarget = query.try_into()?;
let (client, conn) =
connect_as_caller(&db, &authed, &w_id, &datatable_name, role.as_deref()).await?;
let roles = role_map(
&db,
&w_id,
&datatable_name,
&admin_pg_role(&db, &w_id, &datatable_name).await?,
)
.await?;
let owner_row = match &target {
AclTarget::Database => client
.query_opt(
"SELECT pg_get_userbyid(datdba), pg_has_role(datdba, 'USAGE')
FROM pg_database WHERE datname = current_database()",
&[],
)
.await
.map_err(|e| {
Error::internal_err(format!("Failed to read the owner: {}", pg_error_message(&e)))
})?,
AclTarget::Schema { schema } => client
.query_opt(
// `public` is owned by `pg_database_owner`, a placeholder role
// whose membership is whoever owns the database — naming it back
// would say nothing, so resolve it to that owner.
"SELECT pg_get_userbyid(owner), pg_has_role(owner, 'USAGE') FROM (
SELECT CASE WHEN n.nspowner = (SELECT oid FROM pg_roles WHERE rolname = 'pg_database_owner')
THEN (SELECT d.datdba FROM pg_database d WHERE d.datname = current_database())
ELSE n.nspowner END AS owner
FROM pg_namespace n WHERE n.nspname = $1
) o",
&[schema],
)
.await
.map_err(|e| {
Error::internal_err(format!(
"Failed to read the owner: {}",
pg_error_message(&e)
))
})?,
AclTarget::Table { schema, table } => client
.query_opt(
"SELECT pg_get_userbyid(c.relowner), pg_has_role(c.relowner, 'USAGE')
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = $1 AND c.relname = $2",
&[schema, table],
)
.await
.map_err(|e| {
Error::internal_err(format!(
"Failed to read the owner: {}",
pg_error_message(&e)
))
})?,
};
let owner_row = owner_row
.ok_or_else(|| Error::NotFound(format!("{} not found", target.label(&conn.dbname))))?;
let owner: String = owner_row.get(0);
// Membership in the owning role is what Postgres asks for before an ALTER
// ... OWNER or a GRANT on something you do not own; `admin` holds every role
// this feature creates, so it passes everywhere. A workspace admin manages
// the data table itself and is never shut out of it — a schema Windmill did
// not create, `public` above all, is owned by neither.
let can_manage: bool = authed.is_admin || owner_row.get::<_, bool>(1);
let mut grants = read_grants(&client, &target, &roles).await?;
grants.sort_by(|a, b| {
let key = |g: &AclGrant| {
(
g.grantee.clone(),
g.object.as_ref().map(|o| o.name.clone()),
g.future.clone(),
)
};
key(a).cmp(&key(b))
});
let supports_maintain: bool = client
.query_one(
"SELECT current_setting('server_version_num')::int >= 170000",
&[],
)
.await
.map_err(|e| {
Error::internal_err(format!(
"Failed to read the server version: {}",
pg_error_message(&e)
))
})?
.get(0);
// Every role is named, since the list is not what is private — what each of
// them may reach is.
let authed_ref = authed.to_authed_ref();
let datatable = read_datatable(&db, &w_id, &datatable_name).await?;
let usable_roles: Vec<String> = match datatable.permissions.filter(|p| p.enabled) {
Some(p) => p
.roles
.iter()
.filter(|(_, role)| can_use_datatable_role(role, &authed_ref))
.map(|(name, _)| name.clone())
.collect(),
None => vec![ADMIN_DATATABLE_ROLE.to_string()],
};
Ok(Json(DatatableAclInfo {
owner: windmill_role_of(&roles, &owner),
roles: roles.keys().cloned().collect(),
usable_roles,
can_manage,
current_role: windmill_role_of(&roles, &conn.current_user),
supports_maintain,
dbname: conn.dbname,
grants,
}))
}
async fn read_grants(
client: &tokio_postgres::Client,
target: &AclTarget,
roles: &BTreeMap<String, String>,
) -> Result<Vec<AclGrant>> {
// `aclexplode` turns an acl array into one row per (grantee, privilege);
// grantee 0 is PUBLIC, which has no name to resolve.
let mut rows = match target {
AclTarget::Database => client
.query(
"SELECT CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END,
a.privilege_type, NULL::text, NULL::text, NULL::text, NULL::text
FROM pg_database d, aclexplode(d.datacl) a
WHERE d.datname = current_database()",
&[],
)
.await
.map_err(grant_read_error)?,
AclTarget::Schema { schema } => {
let mut out = client
.query(
"SELECT CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END,
a.privilege_type, NULL::text, NULL::text, NULL::text, NULL::text
FROM pg_namespace n, aclexplode(n.nspacl) a
WHERE n.nspname = $1",
&[schema],
)
.await
.map_err(grant_read_error)?;
out.extend(
client
.query(
"SELECT CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END,
a.privilege_type, c.relname, NULL::text,
CASE c.relkind WHEN 'S' THEN 'SEQUENCE' ELSE 'TABLE' END,
NULL::text
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace,
aclexplode(c.relacl) a
WHERE n.nspname = $1",
&[schema],
)
.await
.map_err(grant_read_error)?,
);
out.extend(
client
.query(
// Routines carry their own acl in `pg_proc`; without this
// a grant made here would vanish on the next read and
// could never be revoked back.
"SELECT CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END,
a.privilege_type, p.proname, NULL::text, 'FUNCTION',
pg_get_function_identity_arguments(p.oid)
FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace,
aclexplode(p.proacl) a
WHERE n.nspname = $1",
&[schema],
)
.await
.map_err(grant_read_error)?,
);
out.extend(
client
.query(
"SELECT CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END,
a.privilege_type, NULL::text,
CASE d.defaclobjtype
WHEN 'r' THEN 'TABLES' WHEN 'S' THEN 'SEQUENCES'
WHEN 'f' THEN 'FUNCTIONS' ELSE 'TYPES' END, NULL::text, NULL::text
FROM pg_default_acl d
JOIN pg_namespace n ON n.oid = d.defaclnamespace,
aclexplode(d.defaclacl) a
WHERE n.nspname = $1",
&[schema],
)
.await
.map_err(grant_read_error)?,
);
out
}
AclTarget::Table { schema, table } => client
.query(
"SELECT CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END,
a.privilege_type, NULL::text, NULL::text, NULL::text, NULL::text
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace,
aclexplode(c.relacl) a
WHERE n.nspname = $1 AND c.relname = $2",
&[schema, table],
)
.await
.map_err(grant_read_error)?,
};
// One row per privilege — and, for default privileges, one per creating
// role. Fold them back into one entry per grantee and object.
let mut folded: BTreeMap<
(
String,
Option<(String, String, Option<String>)>,
Option<String>,
),
Vec<String>,
> = BTreeMap::new();
for row in rows.drain(..) {
let grantee: String = row.get(0);
let privilege: String = row.get(1);
let object: Option<String> = row.get(2);
let future: Option<String> = row.get(3);
let object_kind: Option<String> = row.get(4);
let object_args: Option<String> = row.get(5);
folded
.entry((
windmill_role_of(roles, &grantee),
object.map(|name| {
(
name,
object_kind.unwrap_or_else(|| "TABLE".to_string()),
object_args,
)
}),
future,
))
.or_default()
.push(privilege);
}
Ok(folded
.into_iter()
.map(|((grantee, object, future), mut privileges)| {
privileges.sort();
privileges.dedup();
AclGrant {
grantee,
privileges,
object: object.map(|(name, kind, args)| AclObject { name, kind, args }),
future,
}
})
.collect())
}
fn grant_read_error(e: tokio_postgres::Error) -> Error {
Error::internal_err(format!("Failed to read grants: {}", pg_error_message(&e)))
}
async fn build_acl_plan(
db: &DB,
authed: &ApiAuthed,
w_id: &str,
datatable_name: &str,
req: &AclChangeRequest,
) -> Result<(tokio_postgres::Client, AclPlan, String)> {
crate::datatable_permissions::require_datatable_permissions_license().await?;
let (client, conn) =
connect_as_caller(db, authed, w_id, datatable_name, req.role.as_deref()).await?;
// What the caller's own role may change. Postgres cannot enforce the rule we
// want on its own — handing an object to a role you are not a member of is
// refused outright, and granting on one you own needs the grant option — so
// this is the check, and the statements run as the data table's admin below.
if !authed.is_admin && !can_manage_target(&client, &req.target).await? {
return Err(Error::NotAuthorized(format!(
"{} is owned by a role you are not a member of",
req.target.label(&conn.dbname)
)));
}
let roles = role_map(
db,
w_id,
datatable_name,
&admin_pg_role(db, w_id, datatable_name).await?,
)
.await?;
let role_name = match &req.change {
AclChange::SetOwner { role } => role,
AclChange::Grant { role, .. } | AclChange::Revoke { role, .. } => role,
};
let pg_role = pg_role_of(&roles, role_name)?;
let other_pg_roles: Vec<String> = roles
.values()
.filter(|pg| pg.as_str() != pg_role.as_str())
.cloned()
.collect();
let existing_objects = match (&req.change, &req.target) {
(AclChange::SetOwner { .. }, AclTarget::Schema { schema }) => {
read_owned_objects(&client, schema).await?
}
_ => vec![],
};
let plan = crate::datatable_acl_oss::plan_statements(
&req.target,
&req.change,
&conn.dbname,
&pg_role,
&other_pg_roles,
&existing_objects,
)?;
drop(client);
let (admin_client, _) = connect_as_admin(db, w_id, datatable_name).await?;
Ok((admin_client, plan, conn.dbname))
}
async fn plan_datatable_acl(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, datatable_name)): Path<(String, String)>,
Json(req): Json<AclChangeRequest>,
) -> JsonResult<AclPlan> {
let (_client, plan, _dbname) =
build_acl_plan(&db, &authed, &w_id, &datatable_name, &req).await?;
Ok(Json(plan))
}
async fn apply_datatable_acl(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, datatable_name)): Path<(String, String)>,
Json(req): Json<AclChangeRequest>,
) -> Result<String> {
// Granting is passing a privilege on, which this connection cannot do for a
// privilege it holds without the grant option.
crate::datatable_permissions::ensure_instance_db_can_delegate(&db, &w_id, &datatable_name)
.await;
let (mut client, plan, dbname) =
build_acl_plan(&db, &authed, &w_id, &datatable_name, &req).await?;
// One transaction: a half-applied ownership transfer leaves objects of one
// schema owned by two different roles.
let pg_tx = client.transaction().await.map_err(|e| {
Error::internal_err(format!(
"Failed to open a transaction on the data table: {}",
pg_error_message(&e)
))
})?;
for statement in plan.statements.iter() {
pg_tx.batch_execute(statement).await.map_err(|e| {
Error::ExecutionErr(format!(
"Failed to run `{statement}`: {}",
pg_error_message(&e)
))
})?;
}
pg_tx.commit().await.map_err(|e| {
Error::internal_err(format!(
"Failed to commit the changes: {}",
pg_error_message(&e)
))
})?;
audit_log(
&db,
&authed,
"datatables.acl",
ActionKind::Update,
&w_id,
Some(&datatable_name),
Some([("target", format!("{:?}", req.target).as_str())].into()),
)
.await?;
Ok(format!("Updated access on {}", req.target.label(&dbname)))
}