mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-17 08:03:06 +00:00
* feat: let a workspace withdraw operator schedule and trigger writes Operators can create, edit and delete schedules and triggers today through the API, CLI and MCP, while the operator_settings flags beside them only hide those pages. An admin who wants operators to see what is scheduled without letting them change it cannot express that. Add manage_schedules and manage_triggers as enforced settings, gated at the schedule handlers and at the generic TriggerCrud routes so every trigger kind is covered by one check. They name capabilities operators already hold, so they are granted unless withdrawn, and absence has to mean "never configured" rather than a value. The read coalesces to true; the update endpoint merges into the stored jsonb with the two fields as Option<bool>, so an omitted key keeps what is stored. operator_settings is git-synced as a whole object, so a settings file written before these keys existed reaches the endpoint on every pull, and a serde or SQL default of either polarity would turn that pull into a silent withdrawal or restoration. The rights are read through a per-process cache, so withdrawing one publishes a notify_event that drops the entry on every replica. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dsf6VC4MVLisiEoeQkgbr4 * feat: enforce operator write rights on the router and in the UI Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: close the capture gap and gate the trigger editors' write actions Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: gate acl writes and the native trigger drawer behind manage rights Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: refuse operator writes with 403 and gate sharing at the drawer Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * perf: resolve identity in the operator write gate only for writes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: gate the suspended-jobs actions and stop the route check refusing reads Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: explain the empty-state create button when operator writes are withdrawn Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: audit operator settings changes and fold path writes into native rows Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: open locked editors read-only and group the operator settings Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: skip email and azure lookups on editor open while triggers are locked Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs: state each operator-rights rationale once in comments Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: address CI review findings on operator write rights Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: keep capture move gated and skip it in the builders while locked Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: keep admin and operator exclusive when setting a workspace role Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * refactor: use the shared section component for operator settings groups Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
620 lines
21 KiB
Rust
620 lines
21 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.
|
|
*/
|
|
|
|
use axum::{
|
|
extract::{Extension, Path},
|
|
routing::{get, post},
|
|
Json, Router,
|
|
};
|
|
use windmill_api_auth::require_owner_of_path;
|
|
use windmill_audit::audit_oss::audit_log;
|
|
use windmill_audit::ActionKind;
|
|
use windmill_common::scripts::ScriptHash;
|
|
use windmill_common::workspaces::{check_operator_can_manage, ManageKind};
|
|
use windmill_common::DB;
|
|
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
|
|
|
|
use windmill_api_auth::ApiAuthed;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use windmill_common::{
|
|
db::UserDB,
|
|
error::{Error, JsonResult, Result},
|
|
utils::{not_found_if_none, StripPath},
|
|
};
|
|
|
|
/// Map a granular-ACL kind segment to the audit-log action prefix used by the
|
|
/// per-kind CRUD endpoints (e.g. `flows.update`, `scripts.update`). The
|
|
/// resulting action is suffixed with `.grant_acl` / `.revoke_acl` so the
|
|
/// audit log keeps a per-resource record of every `/acls/*` mutation —
|
|
/// folder/group already log via their dedicated permission-history tables.
|
|
fn audit_action_prefix_for_acl_kind(kind: &str) -> Option<&'static str> {
|
|
match kind {
|
|
"script" => Some("scripts"),
|
|
"flow" => Some("flows"),
|
|
"app" => Some("apps"),
|
|
// Distinct prefix so dashboards aggregating on `action` can separate
|
|
// raw_app ACL mutations from regular app ones without parsing the
|
|
// `kind` parameters field. (The granular_acls SQL routes raw_app
|
|
// writes to the same `app` table; audit log identity is separate.)
|
|
"raw_app" => Some("raw_apps"),
|
|
"resource" => Some("resources"),
|
|
"variable" => Some("variables"),
|
|
_ => manage_kind_for_acl_kind(kind).map(|k| k.audit_prefix()),
|
|
}
|
|
}
|
|
|
|
/// The kinds whose writes a workspace can withdraw from operators. `/acls` serves every kind, so it
|
|
/// cannot sit under the routers that layer the gate — see `docs/operator-write-rights.md`.
|
|
fn manage_kind_for_acl_kind(kind: &str) -> Option<ManageKind> {
|
|
match kind {
|
|
"schedule" => Some(ManageKind::Schedules),
|
|
"http_trigger" | "websocket_trigger" | "kafka_trigger" | "nats_trigger"
|
|
| "postgres_trigger" | "mqtt_trigger" | "amqp_trigger" | "gcp_trigger"
|
|
| "azure_trigger" | "sqs_trigger" | "email_trigger" => Some(ManageKind::Triggers),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
const KINDS: [&str; 21] = [
|
|
"script",
|
|
"group_",
|
|
"resource",
|
|
"schedule",
|
|
"variable",
|
|
"flow",
|
|
"folder",
|
|
"app",
|
|
"raw_app",
|
|
"http_trigger",
|
|
"websocket_trigger",
|
|
"kafka_trigger",
|
|
"nats_trigger",
|
|
"postgres_trigger",
|
|
"mqtt_trigger",
|
|
"amqp_trigger",
|
|
"gcp_trigger",
|
|
"azure_trigger",
|
|
"sqs_trigger",
|
|
"email_trigger",
|
|
"volume",
|
|
];
|
|
|
|
pub fn workspaced_service() -> Router {
|
|
Router::new()
|
|
.route("/get/{*path}", get(get_granular_acls))
|
|
.route("/add/{*path}", post(add_granular_acl))
|
|
.route("/remove/{*path}", post(remove_granular_acl))
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct GranularAcl {
|
|
pub owner: String,
|
|
pub write: Option<bool>,
|
|
}
|
|
|
|
async fn add_granular_acl(
|
|
authed: ApiAuthed,
|
|
Extension(db): Extension<DB>,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Path((w_id, path)): Path<(String, StripPath)>,
|
|
Json(GranularAcl { owner, write }): Json<GranularAcl>,
|
|
) -> Result<String> {
|
|
crate::check_demo_workspace_restriction(&authed, &w_id, "Sharing")?;
|
|
let path = path.to_path();
|
|
|
|
let (kind, path) = path
|
|
.split_once('/')
|
|
.ok_or_else(|| Error::BadRequest("Invalid path or kind".to_string()))?;
|
|
|
|
if !KINDS.contains(&kind) {
|
|
return Err(Error::BadRequest("Invalid kind".to_string()));
|
|
}
|
|
|
|
if let Some(manage_kind) = manage_kind_for_acl_kind(kind) {
|
|
check_operator_can_manage(&db, &w_id, authed.is_operator, manage_kind).await?;
|
|
}
|
|
|
|
let identifier = if kind == "group_" || kind == "folder" || kind == "volume" {
|
|
"name"
|
|
} else {
|
|
"path"
|
|
};
|
|
|
|
if !authed.is_admin {
|
|
if kind == "folder" {
|
|
crate::folders::require_is_owner(&authed, path)?;
|
|
} else if kind == "group_" {
|
|
crate::groups::require_is_owner(path, &authed.username, &authed.groups, &w_id, &db)
|
|
.await?;
|
|
} else if kind == "volume" {
|
|
let created_by = sqlx::query_scalar!(
|
|
"SELECT created_by FROM volume WHERE name = $1 AND workspace_id = $2",
|
|
path,
|
|
&w_id
|
|
)
|
|
.fetch_optional(&db)
|
|
.await?
|
|
.ok_or_else(|| Error::NotFound(format!("volume '{path}' not found")))?;
|
|
// created_by is stored with u/ prefix (from job.permissioned_as)
|
|
let owner_username = created_by.strip_prefix("u/").unwrap_or(&created_by);
|
|
if owner_username != authed.username {
|
|
return Err(Error::NotAuthorized(
|
|
"Only the volume owner or an admin can modify permissions".to_string(),
|
|
));
|
|
}
|
|
} else {
|
|
require_owner_of_path(&authed, path)?;
|
|
}
|
|
}
|
|
|
|
let mut tx = user_db.begin(&authed).await?;
|
|
|
|
if kind == "folder" {
|
|
if let Some(obj) = sqlx::query_scalar!(
|
|
"SELECT owners FROM folder WHERE name = $1 AND workspace_id = $2",
|
|
path,
|
|
w_id
|
|
)
|
|
.fetch_optional(&mut *tx)
|
|
.await?
|
|
{
|
|
if obj.contains(&owner) {
|
|
if write != Some(true) {
|
|
return Err(Error::BadRequest(
|
|
"Cannot remove write permission for folder owner".to_string(),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// v2 raw apps are stored in the `app` table (with `app_version.raw_app = true`
|
|
// distinguishing them from regular apps); the legacy `raw_app` table no longer
|
|
// backs the workspace export, so granting/revoking on `raw_app` must hit `app`
|
|
// for the change to be visible. Git-sync dispatch still uses
|
|
// DeployedObject::RawApp so the worker writes back `<path>.raw_app.json`.
|
|
let table = if kind == "raw_app" { "app" } else { kind };
|
|
// SAFETY: `kind` has been validated against the `KINDS` allowlist before reaching this function.
|
|
let obj_o = sqlx::query_scalar::<_, serde_json::Value>(&format!(
|
|
"UPDATE {table} SET extra_perms = jsonb_set(extra_perms, $1, to_jsonb($2), \
|
|
true) WHERE {identifier} = $3 AND workspace_id = $4 RETURNING extra_perms"
|
|
))
|
|
.bind(vec![owner.clone()])
|
|
.bind(write.unwrap_or(false))
|
|
.bind(path)
|
|
.bind(&w_id)
|
|
.fetch_optional(&mut *tx)
|
|
.await?;
|
|
|
|
let _ = not_found_if_none(obj_o, &kind, &path)?;
|
|
|
|
if kind == "folder" {
|
|
let change_type = if write.unwrap_or(false) {
|
|
"grant_write"
|
|
} else {
|
|
"grant_read"
|
|
};
|
|
crate::folders::log_folder_permission_change(
|
|
&mut *tx,
|
|
&w_id,
|
|
path,
|
|
&authed.username,
|
|
change_type,
|
|
Some(&owner),
|
|
)
|
|
.await?;
|
|
} else if kind == "group_" {
|
|
let change_type = if write.unwrap_or(false) {
|
|
"grant_admin"
|
|
} else {
|
|
"grant_member_only"
|
|
};
|
|
crate::groups::log_group_permission_change(
|
|
&mut *tx,
|
|
&w_id,
|
|
path,
|
|
&authed.username,
|
|
change_type,
|
|
Some(&owner),
|
|
)
|
|
.await?;
|
|
} else if let Some(prefix) = audit_action_prefix_for_acl_kind(kind) {
|
|
// Mirror the folder/group permission-history coverage for every other
|
|
// ACLable kind. Folder/group already wrote a dedicated history row
|
|
// above; everything else (script/flow/app/raw_app/resource/...) lands
|
|
// in the general audit_log table here.
|
|
let access = if write.unwrap_or(false) {
|
|
"write"
|
|
} else {
|
|
"read"
|
|
};
|
|
let action = format!("{}.grant_acl", prefix);
|
|
audit_log(
|
|
&mut *tx,
|
|
&authed,
|
|
action.as_str(),
|
|
ActionKind::Update,
|
|
&w_id,
|
|
Some(path),
|
|
Some(
|
|
[
|
|
("kind", kind),
|
|
("owner", owner.as_str()),
|
|
("access", access),
|
|
]
|
|
.into(),
|
|
),
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
tx.commit().await?;
|
|
|
|
match kind {
|
|
"folder" => {
|
|
handle_deployment_metadata(
|
|
&authed.email,
|
|
&authed.username,
|
|
&db,
|
|
&w_id,
|
|
DeployedObject::Folder { path: format!("f/{}", path) },
|
|
Some(format!("Folder '{}' changed permissions", path)),
|
|
true,
|
|
None,
|
|
)
|
|
.await?
|
|
}
|
|
"app" => {
|
|
handle_deployment_metadata(
|
|
&authed.email,
|
|
&authed.username,
|
|
&db,
|
|
&w_id,
|
|
DeployedObject::App { path: path.to_string(), parent_path: None, version: 0 },
|
|
Some(format!("App '{}' changed permissions", path)),
|
|
true,
|
|
None,
|
|
)
|
|
.await?
|
|
}
|
|
"raw_app" => {
|
|
// RawApp deliberately uses its own DeployedObject variant: the
|
|
// git-sync worker reads `path_type` ("app" vs "raw_app") to decide
|
|
// whether to write `<path>.app.json` or `<path>.raw_app.json`.
|
|
// Collapsing this into `App` would dispatch raw_app perm changes
|
|
// against the wrong file in the repo.
|
|
handle_deployment_metadata(
|
|
&authed.email,
|
|
&authed.username,
|
|
&db,
|
|
&w_id,
|
|
DeployedObject::RawApp { path: path.to_string(), parent_path: None, version: 0 },
|
|
Some(format!("Raw App '{}' changed permissions", path)),
|
|
true,
|
|
None,
|
|
)
|
|
.await?
|
|
}
|
|
"script" => {
|
|
handle_deployment_metadata(
|
|
&authed.email,
|
|
&authed.username,
|
|
&db,
|
|
&w_id,
|
|
DeployedObject::Script {
|
|
path: path.to_string(),
|
|
parent_path: None,
|
|
hash: ScriptHash(0),
|
|
},
|
|
Some(format!("Script '{}' changed permissions", path)),
|
|
true,
|
|
None,
|
|
)
|
|
.await?
|
|
}
|
|
"flow" => {
|
|
handle_deployment_metadata(
|
|
&authed.email,
|
|
&authed.username,
|
|
&db,
|
|
&w_id,
|
|
DeployedObject::Flow { path: path.to_string(), parent_path: None, version: 0 },
|
|
Some(format!("Flow '{}' changed permissions", path)),
|
|
true,
|
|
None,
|
|
)
|
|
.await?
|
|
}
|
|
"variable" => {
|
|
handle_deployment_metadata(
|
|
&authed.email,
|
|
&authed.username,
|
|
&db,
|
|
&w_id,
|
|
DeployedObject::Variable { path: path.to_string(), parent_path: None },
|
|
Some(format!("Variable '{}' changed permissions", path)),
|
|
true,
|
|
None,
|
|
)
|
|
.await?
|
|
}
|
|
_ => (),
|
|
}
|
|
|
|
Ok("Successfully modified granular acl".to_string())
|
|
}
|
|
|
|
async fn remove_granular_acl(
|
|
authed: ApiAuthed,
|
|
Extension(db): Extension<DB>,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Path((w_id, path)): Path<(String, StripPath)>,
|
|
Json(GranularAcl { owner, .. }): Json<GranularAcl>,
|
|
) -> Result<String> {
|
|
let path = path.to_path();
|
|
|
|
let (kind, path) = path
|
|
.split_once('/')
|
|
.ok_or_else(|| Error::BadRequest("Invalid path or kind".to_string()))?;
|
|
|
|
if !KINDS.contains(&kind) {
|
|
return Err(Error::BadRequest("Invalid kind".to_string()));
|
|
}
|
|
|
|
if let Some(manage_kind) = manage_kind_for_acl_kind(kind) {
|
|
check_operator_can_manage(&db, &w_id, authed.is_operator, manage_kind).await?;
|
|
}
|
|
|
|
if !authed.is_admin {
|
|
if kind == "folder" {
|
|
crate::folders::require_is_owner(&authed, path)?;
|
|
} else if kind == "group_" {
|
|
crate::groups::require_is_owner(path, &authed.username, &authed.groups, &w_id, &db)
|
|
.await?;
|
|
} else if kind == "volume" {
|
|
let created_by = sqlx::query_scalar!(
|
|
"SELECT created_by FROM volume WHERE name = $1 AND workspace_id = $2",
|
|
path,
|
|
&w_id
|
|
)
|
|
.fetch_optional(&db)
|
|
.await?
|
|
.ok_or_else(|| Error::NotFound(format!("volume '{path}' not found")))?;
|
|
// created_by is stored with u/ prefix (from job.permissioned_as)
|
|
let owner_username = created_by.strip_prefix("u/").unwrap_or(&created_by);
|
|
if owner_username != authed.username {
|
|
return Err(Error::NotAuthorized(
|
|
"Only the volume owner or an admin can modify permissions".to_string(),
|
|
));
|
|
}
|
|
} else {
|
|
require_owner_of_path(&authed, path)?;
|
|
}
|
|
}
|
|
|
|
let mut tx = user_db.begin(&authed).await?;
|
|
|
|
let identifier = if kind == "group_" || kind == "folder" || kind == "volume" {
|
|
"name"
|
|
} else {
|
|
"path"
|
|
};
|
|
|
|
if identifier == "path" {
|
|
require_owner_of_path(&authed, path)?;
|
|
}
|
|
|
|
// See add_granular_acl: kind="raw_app" must hit the `app` table because v2
|
|
// raw apps live there and the legacy `raw_app` table no longer backs the
|
|
// workspace export.
|
|
let table = if kind == "raw_app" { "app" } else { kind };
|
|
// SAFETY: `kind` has been validated against the `KINDS` allowlist before reaching this function.
|
|
// LIMIT 1: `script` shares (workspace_id, path) across versions, so `old` can
|
|
// return >1 row, which would break the scalar subquery in RETURNING.
|
|
let obj_o = sqlx::query_scalar::<_, bool>(&format!(
|
|
"WITH old AS (
|
|
SELECT extra_perms->$1 as old_write FROM {table}
|
|
WHERE {identifier} = $2 AND workspace_id = $3 AND extra_perms ? $1
|
|
)
|
|
UPDATE {table} SET extra_perms = extra_perms - $1
|
|
WHERE {identifier} = $2 AND workspace_id = $3 AND extra_perms ? $1
|
|
RETURNING (SELECT old_write FROM old LIMIT 1)::bool"
|
|
))
|
|
.bind(&owner)
|
|
.bind(path)
|
|
.bind(&w_id)
|
|
.fetch_optional(&mut *tx)
|
|
.await?;
|
|
|
|
// Only log if something was actually removed (obj_o is Some)
|
|
if let Some(write) = obj_o {
|
|
// Log permission changes for folders and groups
|
|
if kind == "folder" {
|
|
let change_type = if write { "revoke_write" } else { "revoke_read" };
|
|
crate::folders::log_folder_permission_change(
|
|
&mut *tx,
|
|
&w_id,
|
|
path,
|
|
&authed.username,
|
|
change_type,
|
|
Some(&owner),
|
|
)
|
|
.await?;
|
|
} else if kind == "group_" {
|
|
crate::groups::log_group_permission_change(
|
|
&mut *tx,
|
|
&w_id,
|
|
path,
|
|
&authed.username,
|
|
"revoke_admin",
|
|
Some(&owner),
|
|
)
|
|
.await?;
|
|
} else if let Some(prefix) = audit_action_prefix_for_acl_kind(kind) {
|
|
// Mirror the add path: standard audit_log row for every kind that
|
|
// doesn't have a dedicated permission-history table.
|
|
let access = if write { "write" } else { "read" };
|
|
let action = format!("{}.revoke_acl", prefix);
|
|
audit_log(
|
|
&mut *tx,
|
|
&authed,
|
|
action.as_str(),
|
|
ActionKind::Update,
|
|
&w_id,
|
|
Some(path),
|
|
Some(
|
|
[
|
|
("kind", kind),
|
|
("owner", owner.as_str()),
|
|
("access", access),
|
|
]
|
|
.into(),
|
|
),
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
tx.commit().await?;
|
|
|
|
match kind {
|
|
"folder" => {
|
|
handle_deployment_metadata(
|
|
&authed.email,
|
|
&authed.username,
|
|
&db,
|
|
&w_id,
|
|
DeployedObject::Folder { path: format!("f/{}", path) },
|
|
Some(format!("Folder '{}' changed permissions", path)),
|
|
true,
|
|
None,
|
|
)
|
|
.await?
|
|
}
|
|
"app" => {
|
|
handle_deployment_metadata(
|
|
&authed.email,
|
|
&authed.username,
|
|
&db,
|
|
&w_id,
|
|
DeployedObject::App { path: path.to_string(), parent_path: None, version: 0 },
|
|
Some(format!("App '{}' changed permissions", path)),
|
|
true,
|
|
None,
|
|
)
|
|
.await?
|
|
}
|
|
"raw_app" => {
|
|
// See add_granular_acl: raw_app must use its own DeployedObject
|
|
// variant so git-sync writes `<path>.raw_app.json`.
|
|
handle_deployment_metadata(
|
|
&authed.email,
|
|
&authed.username,
|
|
&db,
|
|
&w_id,
|
|
DeployedObject::RawApp {
|
|
path: path.to_string(),
|
|
parent_path: None,
|
|
version: 0,
|
|
},
|
|
Some(format!("Raw App '{}' changed permissions", path)),
|
|
true,
|
|
None,
|
|
)
|
|
.await?
|
|
}
|
|
"script" => {
|
|
handle_deployment_metadata(
|
|
&authed.email,
|
|
&authed.username,
|
|
&db,
|
|
&w_id,
|
|
DeployedObject::Script {
|
|
path: path.to_string(),
|
|
parent_path: None,
|
|
hash: ScriptHash(0),
|
|
},
|
|
Some(format!("Script '{}' changed permissions", path)),
|
|
true,
|
|
None,
|
|
)
|
|
.await?
|
|
}
|
|
"flow" => {
|
|
handle_deployment_metadata(
|
|
&authed.email,
|
|
&authed.username,
|
|
&db,
|
|
&w_id,
|
|
DeployedObject::Flow { path: path.to_string(), parent_path: None, version: 0 },
|
|
Some(format!("Flow '{}' changed permissions", path)),
|
|
true,
|
|
None,
|
|
)
|
|
.await?
|
|
}
|
|
"variable" => {
|
|
handle_deployment_metadata(
|
|
&authed.email,
|
|
&authed.username,
|
|
&db,
|
|
&w_id,
|
|
DeployedObject::Variable { path: path.to_string(), parent_path: None },
|
|
Some(format!("Variable '{}' changed permissions", path)),
|
|
true,
|
|
None,
|
|
)
|
|
.await?
|
|
}
|
|
_ => (),
|
|
}
|
|
}
|
|
|
|
Ok("Successfully removed granular acl".to_string())
|
|
}
|
|
|
|
async fn get_granular_acls(
|
|
authed: ApiAuthed,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Path((w_id, path)): Path<(String, StripPath)>,
|
|
) -> JsonResult<serde_json::Value> {
|
|
let path = path.to_path();
|
|
let (kind, path) = path
|
|
.split_once('/')
|
|
.ok_or_else(|| Error::BadRequest("Invalid path or kind".to_string()))?;
|
|
|
|
if !KINDS.contains(&kind) {
|
|
return Err(Error::BadRequest("Invalid kind".to_string()));
|
|
}
|
|
|
|
let mut tx = user_db.begin(&authed).await?;
|
|
|
|
let identifier = if kind == "group_" || kind == "folder" || kind == "volume" {
|
|
"name"
|
|
} else {
|
|
"path"
|
|
};
|
|
// See add_granular_acl: raw_app rows live in the `app` table now, so the
|
|
// read path must also target `app` — otherwise GET would return stale or
|
|
// 404 state while POST /acls/add and /acls/remove write to `app`.
|
|
let table = if kind == "raw_app" { "app" } else { kind };
|
|
// SAFETY: `kind` has been validated against the `KINDS` allowlist before reaching this function.
|
|
let obj_o = sqlx::query_scalar::<_, serde_json::Value>(&format!(
|
|
"SELECT extra_perms from {table} WHERE {identifier} = $1 AND workspace_id = $2"
|
|
))
|
|
.bind(path)
|
|
.bind(w_id)
|
|
.fetch_optional(&mut *tx)
|
|
.await?;
|
|
|
|
let obj = not_found_if_none(obj_o, &kind, &path)?;
|
|
tx.commit().await?;
|
|
|
|
Ok(Json(obj))
|
|
}
|