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
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
//! 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<TenancyOutput> {
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)
{
return Ok(TenancyOutput::TestExtract {
message: format!("extract INVALID — apply would reject: {e}"),
});
}
if let Some(ns) = namespace
&& let Err(e) = validate_namespace(ns)
{
return Ok(TenancyOutput::TestExtract {
message: format!("namespace INVALID — apply would reject: {e}"),
});
}
// No transform ⇒ the verbatim path (the host injects the claim value unchanged, unscreened).
if extract.is_none() && namespace.is_none() {
return Ok(TenancyOutput::TestExtract {
message: format!("no transform configured — tenant = {value:?} (verbatim)"),
});
}
let claim = serde_json::Value::String(value.to_string());
let message = match derive_tenant(extract.as_ref(), namespace, &claim) {
DeriveOutcome::Resolved(key) => format!("resolved tenant key: {key}"),
DeriveOutcome::ClaimNonString => "DENY: claim is not a string".to_string(),
DeriveOutcome::NoMatch { empty_capture } => format!(
"DENY: extraction did not match the value{}",
if empty_capture {
" (matched, but captured the empty string)"
} else {
""
}
),
DeriveOutcome::KeyRejected(reason) => format!("DENY: derived key rejected — {reason}"),
};
Ok(TenancyOutput::TestExtract { message })
}
/// The result of a `tenancy` subcommand, rendered by [`crate::output`].
#[derive(Debug, serde::Serialize)]
#[serde(tag = "result", rename_all = "snake_case")]
pub enum TenancyOutput {
/// `tenancy show` — the project's current tenancy schema.
Show {
schema: boatramp_core::tenancy::TenancySchema,
},
/// A schema was applied; `write_global` lists the writable-unscoped tables
/// surfaced as the operator-trust residual (#503).
Applied {
project: String,
tables: usize,
write_global: Vec<String>,
},
/// The schema was cleared (reverted to legacy Uniform scoping).
Cleared { project: String },
/// `tenancy test-extract` — the local dry-run verdict (resolved key or the exact
/// deny/invalid diagnosis). Carried as the rendered line, which is this diagnostic's
/// whole payload.
TestExtract { message: String },
}
impl crate::output::Rendered for TenancyOutput {
fn render_human(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> {
match self {
Self::Show { schema } => {
let rendered =
serde_json::to_string_pretty(schema).map_err(std::io::Error::other)?;
writeln!(w, "{rendered}")
}
Self::Applied {
project,
tables,
write_global,
} => {
writeln!(
w,
"applied tenancy schema to project `{project}` ({tables} table(s))"
)?;
if !write_global.is_empty() {
writeln!(
w,
" write-global (writable, any scoped route may write UNSTAMPED — ensure these \
are genuinely tenant-less): {}",
write_global.join(", ")
)?;
}
Ok(())
}
Self::Cleared { project } => {
writeln!(
w,
"cleared tenancy schema for project `{project}` (legacy Uniform scoping)"
)
}
Self::TestExtract { message } => writeln!(w, "{message}"),
}
}
}
/// Entry point for `boatramp tenancy`.
pub async fn run(args: TenancyArgs, config: &ProjectConfig) -> Result<TenancyOutput> {
// `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?;
// Surface a serialization failure up-front as `Error::Render` (unchanged error
// path); the human/JSON rendering then renders from the structured `schema`.
serde_json::to_string_pretty(&schema).map_err(Error::Render)?;
Ok(TenancyOutput::Show { schema })
}
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?;
// #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(|&(_name, scope)| {
matches!(
scope,
boatramp_core::tenancy::TableScope::Unscoped { writable: true }
)
})
.map(|(name, _scope)| name.clone())
.collect();
Ok(TenancyOutput::Applied {
project,
tables: schema.tables.len(),
write_global,
})
}
TenancyCommand::Clear => {
cp.clear_project_tenancy().await?;
Ok(TenancyOutput::Cleared { project })
}
// Handled by the early-return local dry-run above (no control plane).
TenancyCommand::TestExtract { .. } => {
unreachable!("test-extract is handled before connect")
}
}
}
#[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);
}
}