1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
//! The `tenancy` subcommand: manage a project's tenancy schema — the per-table
//! tenant-key map the host scope injector consults when scoping guest `sql`/`orm`
//! queries.
//!
//! The schema names each table's [`TableScope`](boatramp_core::tenancy::TableScope):
//! `tenant` (keyed on the project default tenant column), `tenant_keyed` (keyed on a
//! named column — e.g. the identity table on its own primary key), or `unscoped` (a
//! shared reference table, no tenant predicate). A table that appears in **no** entry
//! is **denied** — deny-by-default — so a query touching it is refused rather than
//! silently unscoped.
//!
//! Mutating the schema redraws the isolation boundary for every guest query, so the
//! server gates `apply`/`clear` at `Project·Admin` (never the deploy-grade publisher
//! right). Scoping follows the uniform project rule: the global `--project` /
//! `BOATRAMP_PROJECT` flag selects the tenant, exactly like `secrets` / `email`.
//!
//! The on-disk format is JSON (round-trips cleanly with `show`): `boatramp tenancy
//! show > schema.json`, edit, `boatramp tenancy apply schema.json`.
use clap::Subcommand;
use crate::client;
use crate::config::ProjectConfig;
/// A failure in the `tenancy` subcommand.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// Resolving the server / building the client failed.
#[error(transparent)]
Client(#[from] crate::client::ClientError),
/// An HTTP request to the control plane failed (incl. a non-2xx status — e.g. a
/// `403` when the token lacks `Project·Admin`).
#[error(transparent)]
Http(#[from] reqwest::Error),
/// Reading the schema file failed.
#[error("reading schema file {path}: {source}")]
Read {
/// The path that could not be read.
path: String,
/// The underlying IO error.
#[source]
source: std::io::Error,
},
/// The schema file was not valid JSON for a [`TenancySchema`].
#[error("parsing schema file {path}: {source}")]
Parse {
/// The path whose contents did not parse.
path: String,
/// The underlying JSON error.
#[source]
source: serde_json::Error,
},
/// Serializing the fetched schema for display failed (should never happen).
#[error("rendering schema: {0}")]
Render(#[source] serde_json::Error),
}
/// `tenancy` module result; `Err` is [`Error`].
type Result<T> = std::result::Result<T, Error>;
/// Arguments for `boatramp tenancy`.
#[derive(Debug, clap::Args)]
pub struct TenancyArgs {
/// boatramp server base URL (overrides [publish].server).
#[arg(long, env = "BOATRAMP_SERVER", global = true)]
server: Option<String>,
#[command(subcommand)]
command: TenancyCommand,
}
#[derive(Debug, Subcommand)]
enum TenancyCommand {
/// Print the project's current tenancy schema as JSON. A project that declared
/// none prints the default schema (`tenant_id`, no tables) — legacy single-column
/// scoping.
Show,
/// Replace the project's tenancy schema from a JSON file (`Project·Admin`).
Apply {
/// Path to the schema JSON (as produced by `tenancy show`).
file: std::path::PathBuf,
},
/// Clear the project's tenancy schema, reverting to legacy `Uniform` single-column
/// scoping (`Project·Admin`). Idempotent.
Clear,
/// Dry-run a `token`-source claim transform against a sample claim value, **locally**
/// (no server), through the SAME `derive_tenant` the host runs. Prints the derived
/// tenant key, or the exact stage that denied it (the same deny taxonomy the host
/// logs) — so a regex/template misconfiguration is a 30-second check instead of
/// staring at silently fail-closed requests.
TestExtract {
/// The claim's sample VALUE (e.g. a Salesforce `sub` identity URL).
#[arg(long)]
value: String,
/// A `{tenant}`/`{_}` path template (the recommended surface).
#[arg(long, conflicts_with = "regex")]
template: Option<String>,
/// A single-named-capture regex `(?<tenant>…)` (the escape hatch).
#[arg(long)]
regex: Option<String>,
/// The optional per-issuer namespace (produces `<namespace>:<extracted>`).
#[arg(long)]
namespace: Option<String>,
},
}
/// Local dry-run of the `token`-source claim transform (the `test-extract` subcommand). No server;
/// runs the host's real [`derive_tenant`] and apply-time validators so the verdict matches production.
fn test_extract(
value: &str,
template: Option<&str>,
regex: Option<&str>,
namespace: Option<&str>,
) -> Result<()> {
use boatramp_core::claim_extract::{
DeriveOutcome, derive_tenant, validate_extract, validate_namespace,
};
use boatramp_core::tenancy::{ClaimExtract, ExtractSyntax};
let extract = match (template, regex) {
(Some(t), _) => Some(ClaimExtract {
syntax: ExtractSyntax::Template,
pattern: t.to_string(),
}),
(None, Some(r)) => Some(ClaimExtract {
syntax: ExtractSyntax::Regex,
pattern: r.to_string(),
}),
(None, None) => None,
};
// Report config that apply would reject, before attempting the match.
if let Some(ext) = &extract
&& let Err(e) = validate_extract(ext)
{
println!("extract INVALID — apply would reject: {e}");
return Ok(());
}
if let Some(ns) = namespace
&& let Err(e) = validate_namespace(ns)
{
println!("namespace INVALID — apply would reject: {e}");
return Ok(());
}
// No transform ⇒ the verbatim path (the host injects the claim value unchanged, unscreened).
if extract.is_none() && namespace.is_none() {
println!("no transform configured — tenant = {value:?} (verbatim)");
return Ok(());
}
let claim = serde_json::Value::String(value.to_string());
match derive_tenant(extract.as_ref(), namespace, &claim) {
DeriveOutcome::Resolved(key) => println!("resolved tenant key: {key}"),
DeriveOutcome::ClaimNonString => println!("DENY: claim is not a string"),
DeriveOutcome::NoMatch { empty_capture } => println!(
"DENY: extraction did not match the value{}",
if empty_capture {
" (matched, but captured the empty string)"
} else {
""
}
),
DeriveOutcome::KeyRejected(reason) => println!("DENY: derived key rejected — {reason}"),
}
Ok(())
}
/// Entry point for `boatramp tenancy`.
pub async fn run(args: TenancyArgs, config: &ProjectConfig) -> Result<()> {
// `test-extract` is a LOCAL dry-run — handle it before touching the control plane.
if let TenancyCommand::TestExtract {
value,
template,
regex,
namespace,
} = &args.command
{
return test_extract(
value,
template.as_deref(),
regex.as_deref(),
namespace.as_deref(),
);
}
let (server, http) = client::connect(args.server.clone(), config)?;
let project = client::resolve_project(config);
let cp = client::ControlPlane::new(server, http, project.clone());
match args.command {
TenancyCommand::Show => {
let schema = cp.get_project_tenancy().await?;
let rendered = serde_json::to_string_pretty(&schema).map_err(Error::Render)?;
println!("{rendered}");
}
TenancyCommand::Apply { file } => {
let path = file.display().to_string();
let bytes = std::fs::read(&file).map_err(|source| Error::Read {
path: path.clone(),
source,
})?;
let schema: boatramp_core::tenancy::TenancySchema = serde_json::from_slice(&bytes)
.map_err(|source| Error::Parse {
path: path.clone(),
source,
})?;
cp.put_project_tenancy(&schema).await?;
println!(
"applied tenancy schema to project `{project}` ({} table(s))",
schema.tables.len()
);
// #503: surface the WRITE-GLOBAL tables so the operator sees exactly which shared tables
// any scoped route may write UNSTAMPED — the irreducible operator-trust residual (the
// host cannot verify a declared-global table is truly tenant-less; a misdeclaration lets
// any granted route write across tenants).
let write_global: Vec<&String> = schema
.tables
.iter()
.filter_map(|(name, scope)| {
matches!(
scope,
boatramp_core::tenancy::TableScope::Unscoped { writable: true }
)
.then_some(name)
})
.collect();
if !write_global.is_empty() {
let names: Vec<&str> = write_global.iter().map(|s| s.as_str()).collect();
println!(
" write-global (writable, any scoped route may write UNSTAMPED — ensure these \
are genuinely tenant-less): {}",
names.join(", ")
);
}
}
TenancyCommand::Clear => {
cp.clear_project_tenancy().await?;
println!("cleared tenancy schema for project `{project}` (legacy Uniform scoping)");
}
// Handled by the early-return local dry-run above (no control plane).
TenancyCommand::TestExtract { .. } => {
unreachable!("test-extract is handled before connect")
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
/// A minimal top-level parser mirroring `main`'s: the global `--project` flag +
/// the `tenancy` subcommand, so we can arg-parse `boatramp tenancy …` in isolation.
#[derive(Parser)]
struct Cli {
#[arg(long, global = true, env = "BOATRAMP_PROJECT")]
project: Option<String>,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
Tenancy(TenancyArgs),
}
fn parse(argv: &[&str]) -> std::result::Result<Cli, clap::Error> {
Cli::try_parse_from(std::iter::once("boatramp").chain(argv.iter().copied()))
}
#[test]
fn subcommands_parse() {
assert!(parse(&["tenancy", "show"]).is_ok());
assert!(parse(&["tenancy", "apply", "schema.json"]).is_ok());
assert!(parse(&["tenancy", "clear"]).is_ok());
// `apply` needs a file argument.
assert!(parse(&["tenancy", "apply"]).is_err());
// `test-extract` needs a --value; --template and --regex are mutually exclusive.
assert!(
parse(&[
"tenancy",
"test-extract",
"--value",
"https://login.salesforce.com/id/00D/005",
"--template",
"https://login.salesforce.com/id/{tenant}/{_}",
"--namespace",
"sfdc",
])
.is_ok()
);
assert!(parse(&["tenancy", "test-extract"]).is_err()); // --value required
assert!(
parse(&[
"tenancy",
"test-extract",
"--value",
"x",
"--template",
"{tenant}",
"--regex",
"(?<tenant>.+)",
])
.is_err() // template + regex conflict
);
}
#[test]
fn test_extract_runs_the_real_derivation_offline() {
// The dry-run resolves the SF org id and namespaces it (no server).
super::test_extract(
"https://login.salesforce.com/id/00D5f0000000abcEAA/0055f00000ABC",
Some("https://login.salesforce.com/id/{tenant}/{_}"),
None,
Some("sfdc"),
)
.expect("dry-run succeeds");
// A non-matching value denies (prints DENY), still Ok.
super::test_extract("not-a-url", Some("https://x/{tenant}"), None, Some("sfdc"))
.expect("dry-run of a non-match still returns Ok (prints a DENY diagnosis)");
}
#[test]
fn the_global_project_flag_reaches_the_subcommand() {
let cli = parse(&["tenancy", "--project", "acme", "show"]).expect("parses");
assert_eq!(cli.project.as_deref(), Some("acme"));
let cli = parse(&["tenancy", "show"]).expect("parses");
assert_eq!(cli.project, None);
}
}