Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 229 additions & 0 deletions apps/cli/src/admin.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
//! `spky admin` — manage the sp00ky operator roster (`_00_admin`).
//!
//! The `user` table belongs to the app, not to sp00ky, so there is no `role`
//! field we can rely on — and a self-writable one would be a privilege
//! escalation hole. `_00_admin` is the whole admin concept instead: a row
//! there means "may edit feature flags from the DevTools panel".
//!
//! The table denies create/update/delete unconditionally, so it can only be
//! written by root — i.e. by this command. Nobody promotes themselves.
//!
//! What an admin can actually do is defined in `meta_tables_remote.surql`:
//! read `_00_feature_flag`, flip `enabled`, edit allowlist rules via
//! `fn::feature::allow` / `fn::feature::disallow`, and write the
//! `_00_user_feature` rows those functions materialize. Creating and deleting
//! flags stays root-only (`spky flag create|delete`).

use anyhow::{Context, Result};
use serde_json::Value;
use std::path::PathBuf;

use crate::flag::{client_from, esc, load_user_id, rows};
use crate::surreal_client::MigrationDB;
use crate::AdminCommands;

const GREEN: &str = "\x1b[32m";
const YELLOW: &str = "\x1b[33m";
const DIM: &str = "\x1b[2m";
const BOLD: &str = "\x1b[1m";
const RESET: &str = "\x1b[0m";

pub fn run(action: AdminCommands) -> Result<()> {
match action {
AdminCommands::List { conn, config } => list(conn, config),
AdminCommands::Add {
user,
note,
conn,
config,
} => add(user, note, conn, config),
AdminCommands::Remove { user, conn, config } => remove(user, conn, config),
}
}

fn list(conn: crate::ConnectionArgs, config: Option<PathBuf>) -> Result<()> {
let client = client_from(conn, config)?;
let resp = client
.execute(
"SELECT <string>user AS user, user.username AS username, note, added_at \
FROM _00_admin ORDER BY added_at ASC;",
)
.context("Failed to list admins")?;
let admins = rows(resp);
if admins.is_empty() {
println!("{}No admins. Add one with `spky admin add <user>`.{}", DIM, RESET);
return Ok(());
}
println!("{}USER ID NOTE{}", BOLD, RESET);
for a in admins {
let username = a
.get("username")
.and_then(Value::as_str)
.unwrap_or("(deleted)");
let id = a.get("user").and_then(Value::as_str).unwrap_or("?");
let note = a.get("note").and_then(Value::as_str).unwrap_or("");
println!("{:<25} {:<25} {}", username, id, note);
}
Ok(())
}

fn add(
user: String,
note: Option<String>,
conn: crate::ConnectionArgs,
config: Option<PathBuf>,
) -> Result<()> {
let client = client_from(conn, config)?;
// Accepts a username or a `user:xxx` record id.
let user_id = load_user_id(&client, &user)?;

// `user_id` is a DB-sourced record id and is interpolated as a record
// reference (the same shape `flag.rs::materialize` uses); the note is
// escaped as a string literal.
let note_sql = match &note {
Some(n) => format!("'{}'", esc(n)),
None => "NONE".to_string(),
};
let query = format!(
"UPSERT _00_admin SET user = {uid}, note = {note} WHERE user = {uid};",
uid = user_id,
note = note_sql
);
client
.execute(&query)
.context("Failed to add admin. Has the internal schema been applied (`spky migrate`)?")?;

println!(
"{}Added{} '{}' ({}) as an admin.",
GREEN, RESET, user, user_id
);
println!(
"{}They can now edit feature flags from the DevTools Flags tab. No re-login needed —\n\
$auth.id is evaluated per query.{}",
DIM, RESET
);
Ok(())
}

fn remove(user: String, conn: crate::ConnectionArgs, config: Option<PathBuf>) -> Result<()> {
let client = client_from(conn, config)?;
let user_id = load_user_id(&client, &user)?;

let existing = rows(
client
.execute(&format!(
"SELECT VALUE id FROM _00_admin WHERE user = {};",
user_id
))
.context("Failed to check admin roster")?,
);
if existing.is_empty() {
println!("{}'{}' is not an admin. Nothing to do.{}", YELLOW, user, RESET);
return Ok(());
}

client
.execute(&format!("DELETE _00_admin WHERE user = {};", user_id))
.context("Failed to remove admin")?;

println!("{}Removed{} '{}' from the admin roster.", GREEN, RESET, user);
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

const META_TABLES_REMOTE: &str = include_str!("meta_tables_remote.surql");

/// `DEFINE FUNCTION` defaults to `PERMISSIONS FULL` in SurrealDB — omitting
/// the clause does NOT make a function root-only, it exposes it to every
/// signed-in user. `fn::feature::materialize` / `allow` / `disallow` write
/// flags for ALL users, so a missing clause here ships a self-service flag
/// editor. `fn::feature::hash` is pure and deliberately callable.
///
/// This is a string check on purpose: it fails at `cargo test` rather than
/// after a deploy, and it needs no database.
#[test]
fn every_feature_mutation_function_declares_permissions() {
let mutating = ["materialize", "allow", "disallow"];
for name in mutating {
let marker = format!("DEFINE FUNCTION OVERWRITE fn::feature::{name}(");
let start = META_TABLES_REMOTE
.find(&marker)
.unwrap_or_else(|| panic!("fn::feature::{name} is missing from the schema"));
// The body ends at the next DEFINE; the PERMISSIONS clause sits
// between the closing brace and that boundary.
let rest = &META_TABLES_REMOTE[start + marker.len()..];
let end = rest.find("\nDEFINE ").unwrap_or(rest.len());
assert!(
rest[..end].contains("PERMISSIONS WHERE"),
"fn::feature::{name} has no PERMISSIONS clause, so SurrealDB defaults it to \
FULL and any signed-in user can rewrite feature flags"
);
}
}

/// The admin gate is repeated in four places (two tables, three functions).
/// If one copy drifts, that gate opens wider than the others and nothing
/// else would notice.
#[test]
fn the_admin_predicate_is_identical_everywhere() {
let predicate =
"array::len((SELECT VALUE id FROM _00_admin WHERE user = $auth.id LIMIT 1)) > 0";
assert_eq!(
META_TABLES_REMOTE.matches(predicate).count(),
5,
"expected the admin predicate on _00_feature_flag, _00_user_feature and the three \
fn::feature::* mutations — a differing count means one gate has drifted"
);
}

/// `_00_admin` must never become client-writable: the whole point is that
/// nobody can promote themselves.
#[test]
fn admin_roster_denies_client_writes() {
let start = META_TABLES_REMOTE
.find("DEFINE TABLE OVERWRITE _00_admin")
.expect("_00_admin table definition missing");
let rest = &META_TABLES_REMOTE[start..];
let end = rest.find("\nDEFINE FIELD").unwrap_or(rest.len());
assert!(
rest[..end].contains("FOR create, update, delete NONE"),
"_00_admin must deny create/update/delete to record tokens"
);
}

/// The record id must be interpolated as a record reference, not quoted:
/// `user = 'user:abc'` compares a string against a `record` field and
/// silently matches nothing, which would make `add` a no-op and `remove`
/// claim the user isn't an admin.
#[test]
fn add_interpolates_the_record_id_unquoted() {
let uid = "user:abc";
let query = format!(
"UPSERT _00_admin SET user = {uid}, note = {note} WHERE user = {uid};",
uid = uid,
note = "NONE"
);
assert!(query.contains("SET user = user:abc,"));
assert!(query.contains("WHERE user = user:abc;"));
assert!(!query.contains("'user:abc'"));
}

#[test]
fn note_is_escaped_as_a_string_literal() {
let note_sql = format!("'{}'", esc("it's fine"));
assert_eq!(note_sql, "'it\\'s fine'");
}

#[test]
fn absent_note_writes_none_not_an_empty_string() {
let note: Option<String> = None;
let note_sql = match &note {
Some(n) => format!("'{}'", esc(n)),
None => "NONE".to_string(),
};
assert_eq!(note_sql, "NONE");
}
}
20 changes: 12 additions & 8 deletions apps/cli/src/flag.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@
//!
//! Writes flag definitions to `_00_feature_flag` and materializes per-user
//! assignments into `_00_user_feature` by running the evaluator in-process.
//! Both tables are root-only (PERMISSIONS NONE on definitions, NONE on
//! create/update/delete for assignments), so clients cannot self-enable or
//! see other users' rows.
//!
//! Ordinary clients can neither read the definitions nor write assignments:
//! `_00_feature_flag` is invisible to them and `_00_user_feature` is
//! read-your-own. Users listed in `_00_admin` (see `spky admin`) are the one
//! exception — they can flip `enabled` and edit allowlist rules from the
//! DevTools panel via `fn::feature::materialize`. Flag creation and deletion
//! stay root-only, i.e. this command.
//!
//! The percentage-rollout hash here must match `fn::feature::hash` in
//! `apps/cli/src/meta_tables_remote.surql`. Both sides take the first 8
Expand Down Expand Up @@ -75,7 +79,7 @@ pub fn run(action: FlagCommands) -> Result<()> {
// Connection
// =============================================================

fn client_from(conn: ConnectionArgs, config: Option<PathBuf>) -> Result<SurrealClient> {
pub(crate) fn client_from(conn: ConnectionArgs, config: Option<PathBuf>) -> Result<SurrealClient> {
// `--cloud` resolves the deployment's SurrealDB URL + root password from
// Sp00ky Cloud; otherwise the local URL/ns/db come from sp00ky.yml.
let c = conn.resolve(&config)?;
Expand All @@ -92,11 +96,11 @@ fn client_from(conn: ConnectionArgs, config: Option<PathBuf>) -> Result<SurrealC
// SurrealQL helpers
// =============================================================

fn esc(s: &str) -> String {
pub(crate) fn esc(s: &str) -> String {
s.replace('\\', "\\\\").replace('\'', "\\'")
}

fn first_row(responses: Vec<SurrealResponse>) -> Option<Value> {
pub(crate) fn first_row(responses: Vec<SurrealResponse>) -> Option<Value> {
let first = responses.into_iter().next()?;
let result = first.result?;
match result {
Expand All @@ -105,7 +109,7 @@ fn first_row(responses: Vec<SurrealResponse>) -> Option<Value> {
}
}

fn rows(responses: Vec<SurrealResponse>) -> Vec<Value> {
pub(crate) fn rows(responses: Vec<SurrealResponse>) -> Vec<Value> {
let first = match responses.into_iter().next() {
Some(r) => r,
None => return vec![],
Expand All @@ -126,7 +130,7 @@ fn load_flag(client: &SurrealClient, key: &str) -> Result<Value> {
first_row(resp).ok_or_else(|| anyhow!("Flag '{}' not found", key))
}

fn load_user_id(client: &SurrealClient, who: &str) -> Result<String> {
pub(crate) fn load_user_id(client: &SurrealClient, who: &str) -> Result<String> {
if who.starts_with("user:") {
return Ok(who.to_string());
}
Expand Down
49 changes: 49 additions & 0 deletions apps/cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod add_api;
mod admin;
mod agents;
mod annotations;
mod backend;
Expand Down Expand Up @@ -360,6 +361,11 @@ enum Commands {
#[command(subcommand)]
action: FlagCommands,
},
/// Manage who may edit feature flags from the DevTools panel
Admin {
#[command(subcommand)]
action: AdminCommands,
},
/// Inspect and control server-side schedules (`schedules:` in sp00ky.yml)
Schedules {
#[command(flatten)]
Expand Down Expand Up @@ -459,6 +465,48 @@ enum AgentsCommands {
},
}

/// `spky admin` — the sp00ky operator roster (`_00_admin`).
///
/// Root-only by construction: the table denies create/update/delete to every
/// record token, so this command is the only way in or out. An admin may read
/// flag definitions and flip flags from the DevTools panel; creating and
/// deleting flags stays with `spky flag`.
#[derive(Subcommand, Debug)]
enum AdminCommands {
/// List the current admins
#[command(visible_alias = "ls")]
List {
#[command(flatten)]
conn: ConnectionArgs,
/// Path to sp00ky.yml config file
#[arg(long)]
config: Option<PathBuf>,
},
/// Grant a user admin rights over feature flags
Add {
/// Username or `user:xxx` record id
user: String,
/// Optional note (e.g. why, or who asked)
#[arg(long)]
note: Option<String>,
#[command(flatten)]
conn: ConnectionArgs,
/// Path to sp00ky.yml config file
#[arg(long)]
config: Option<PathBuf>,
},
/// Revoke a user's admin rights
Remove {
/// Username or `user:xxx` record id
user: String,
#[command(flatten)]
conn: ConnectionArgs,
/// Path to sp00ky.yml config file
#[arg(long)]
config: Option<PathBuf>,
},
}

#[derive(Subcommand, Debug)]
enum FlagCommands {
/// List all feature flag definitions
Expand Down Expand Up @@ -3051,6 +3099,7 @@ fn main() -> Result<()> {
Some(Commands::Backup { action }) => cloud::backup(action),
Some(Commands::Link { action }) => cloud::link(action),
Some(Commands::Flag { action }) => flag::run(action),
Some(Commands::Admin { action }) => admin::run(action),
Some(Commands::Jobs {
conn,
config,
Expand Down
Loading
Loading