cli.rs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556
  1. //! Operator CLI: `serve`, `migrate`, `user`, `scope`, `redirect-uri`,
  2. //! `client`, `config`, `admin`, `initial-access-token`, `keygen`, `healthcheck`.
  3. //!
  4. //! Administration is CLI + declarative config only (no HTTP admin API). No
  5. //! default credential ships and nothing is auto-created at startup: users,
  6. //! scopes, redirect URIs, and initial-access tokens exist only because an
  7. //! operator asked for them. Passwords, TOTP secrets, and tokens are read from
  8. //! stdin/env/file, **never argv**, and a secret is printed only when the
  9. //! operator explicitly asked for a one-time value (a new user's password must
  10. //! be supplied; `user enroll-totp` prints the enrollment secret once;
  11. //! `initial-access-token issue` prints the token once).
  12. //!
  13. //! `migrate` is forward-only and applies `sqlx::migrate!` under sqlx's own
  14. //! Postgres advisory lock; `serve` never migrates. Mutations are audited on the
  15. //! dedicated `audit` tracing target.
  16. //!
  17. //! Storage: the database repository modules are owned by sibling tasks, so the
  18. //! CLI (which owns operator provisioning end-to-end) uses runtime `sqlx`
  19. //! queries against the migrated schema rather than `query!` macros, keeping the
  20. //! command surface independent of the `.sqlx` cache. See
  21. //! `.omo/evidence/task-14-rust-oauth21-server.md`.
  22. use std::path::PathBuf;
  23. use clap::{Parser, Subcommand, ValueEnum};
  24. mod admin;
  25. mod context;
  26. mod ops;
  27. mod pii;
  28. mod redirect;
  29. mod scope;
  30. mod secret;
  31. mod sync;
  32. mod totp;
  33. mod user;
  34. #[cfg(test)]
  35. mod test_support;
  36. #[cfg(test)]
  37. mod tests;
  38. pub use context::CliError;
  39. /// ES256 is the only `keygen` algorithm (P-256 / SHA-256).
  40. #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
  41. pub enum KeyAlg {
  42. /// ECDSA P-256 with SHA-256 (`ES256`).
  43. Es256,
  44. }
  45. /// `d-oauth-server` operator command tree.
  46. #[derive(Debug, Parser)]
  47. #[command(
  48. name = "d-oauth-server",
  49. version,
  50. about = "OAuth 2.1-only authorization server operator CLI",
  51. propagate_version = true
  52. )]
  53. pub struct Cli {
  54. /// Subcommand to run.
  55. #[command(subcommand)]
  56. pub command: Command,
  57. }
  58. /// Top-level operator commands.
  59. #[derive(Debug, Subcommand)]
  60. pub enum Command {
  61. /// Run the HTTP server. Never applies migrations.
  62. Serve,
  63. /// Apply forward-only migrations under sqlx's advisory lock.
  64. Migrate,
  65. /// Manage admin-provisioned users.
  66. User {
  67. /// User subcommand.
  68. #[command(subcommand)]
  69. command: UserCommand,
  70. },
  71. /// Manage the runtime scope catalog.
  72. Scope {
  73. /// Scope subcommand.
  74. #[command(subcommand)]
  75. command: ScopeCommand,
  76. },
  77. /// Manage the runtime redirect-URI catalog.
  78. RedirectUri {
  79. /// Redirect-URI subcommand.
  80. #[command(subcommand)]
  81. command: RedirectUriCommand,
  82. },
  83. /// Manage per-client redirect-URI attachments.
  84. Client {
  85. /// Client subcommand.
  86. #[command(subcommand)]
  87. command: ClientCommand,
  88. },
  89. /// Validate configuration or apply a declarative file.
  90. Config {
  91. /// Config subcommand.
  92. #[command(subcommand)]
  93. command: ConfigCommand,
  94. },
  95. /// Bootstrap operations (admin bootstrap lands in a later task).
  96. Admin {
  97. /// Admin subcommand.
  98. #[command(subcommand)]
  99. command: AdminCommand,
  100. },
  101. /// Re-wrap v3 TOTP secret DEKs under the active TOTP-encryption key.
  102. Totp {
  103. /// TOTP subcommand.
  104. #[command(subcommand)]
  105. command: TotpCommand,
  106. },
  107. /// Backfill and verify sealed PII (migration-role DSN; counts only).
  108. Pii {
  109. /// PII subcommand.
  110. #[command(subcommand)]
  111. command: PiiCommand,
  112. },
  113. /// Issue a one-time DCR initial-access token.
  114. InitialAccessToken {
  115. /// Initial-access-token subcommand.
  116. #[command(subcommand)]
  117. command: InitialAccessTokenCommand,
  118. },
  119. /// Generate an ES256 keypair for test fixtures / client DPoP keys only.
  120. ///
  121. /// This is NEVER an authorization-server signing key: the server publishes
  122. /// no JWKS and signs no AS artifact.
  123. Keygen {
  124. /// Key algorithm (ES256 only).
  125. #[arg(long, value_enum, default_value = "es256")]
  126. alg: KeyAlg,
  127. },
  128. /// Self-probe `/health/live` over HTTP/loopback; exits 0 (live) or 1.
  129. ///
  130. /// Used as the container `HEALTHCHECK` because a minimal image has no
  131. /// shell or curl.
  132. Healthcheck {
  133. /// Health URL (loopback by default).
  134. #[arg(long, default_value = "http://127.0.0.1:8081/health/live")]
  135. url: String,
  136. },
  137. }
  138. /// `user` subcommands.
  139. #[derive(Debug, Subcommand)]
  140. pub enum UserCommand {
  141. /// Create a user; password comes from stdin or a file, never argv.
  142. Create {
  143. /// User email (case-insensitive).
  144. #[arg(long)]
  145. email: String,
  146. /// Grant the `is_admin` flag.
  147. #[arg(long)]
  148. admin: bool,
  149. /// Read the password from stdin (default when `--password-file` is absent).
  150. #[arg(long)]
  151. password_stdin: bool,
  152. /// Read the password from a file (trailing newline trimmed).
  153. #[arg(long, value_name = "PATH")]
  154. password_file: Option<PathBuf>,
  155. },
  156. /// Enroll TOTP: generates, encrypts, and stores a secret, printing it once.
  157. EnrollTotp {
  158. /// User email.
  159. #[arg(long)]
  160. email: String,
  161. },
  162. /// Disable a user account.
  163. Disable {
  164. /// User email.
  165. #[arg(long)]
  166. email: String,
  167. },
  168. /// Grant a catalog scope entitlement to a user.
  169. GrantScope {
  170. /// User email.
  171. #[arg(long)]
  172. email: String,
  173. /// Scope name (must exist in the catalog).
  174. #[arg(long)]
  175. scope: String,
  176. },
  177. /// Revoke a catalog scope entitlement from a user.
  178. RevokeScope {
  179. /// User email.
  180. #[arg(long)]
  181. email: String,
  182. /// Scope name.
  183. #[arg(long)]
  184. scope: String,
  185. },
  186. /// List a user's scope entitlements.
  187. Scopes {
  188. /// User email.
  189. #[arg(long)]
  190. email: String,
  191. },
  192. }
  193. /// `scope` subcommands.
  194. #[derive(Debug, Subcommand)]
  195. pub enum ScopeCommand {
  196. /// Create a scope (or update its description when it exists).
  197. Create {
  198. /// Scope name.
  199. name: String,
  200. /// Optional human description.
  201. #[arg(long)]
  202. description: Option<String>,
  203. },
  204. /// List the scope catalog.
  205. List,
  206. /// Enable a scope.
  207. Enable {
  208. /// Scope name.
  209. name: String,
  210. },
  211. /// Disable a scope.
  212. Disable {
  213. /// Scope name.
  214. name: String,
  215. },
  216. /// Delete a scope (referenced scopes are disabled instead).
  217. Delete {
  218. /// Scope name.
  219. name: String,
  220. },
  221. }
  222. /// `redirect-uri` subcommands.
  223. #[derive(Debug, Subcommand)]
  224. pub enum RedirectUriCommand {
  225. /// Create a redirect URI (or update its description when it exists).
  226. Create {
  227. /// Absolute redirect URI.
  228. uri: String,
  229. /// Catalog kind: `authorize` (default) or `post_logout`.
  230. #[arg(long, default_value = "authorize", value_parser = ["authorize", "post_logout"])]
  231. kind: String,
  232. /// Optional human description.
  233. #[arg(long)]
  234. description: Option<String>,
  235. },
  236. /// List the redirect-URI catalog.
  237. List,
  238. /// Enable a redirect URI.
  239. Enable {
  240. /// Redirect URI.
  241. uri: String,
  242. /// Catalog kind: `authorize` (default) or `post_logout`.
  243. #[arg(long, default_value = "authorize", value_parser = ["authorize", "post_logout"])]
  244. kind: String,
  245. },
  246. /// Disable a redirect URI.
  247. Disable {
  248. /// Redirect URI.
  249. uri: String,
  250. /// Catalog kind: `authorize` (default) or `post_logout`.
  251. #[arg(long, default_value = "authorize", value_parser = ["authorize", "post_logout"])]
  252. kind: String,
  253. },
  254. /// Delete a redirect URI (referenced URIs are disabled instead).
  255. Delete {
  256. /// Redirect URI.
  257. uri: String,
  258. /// Catalog kind: `authorize` (default) or `post_logout`.
  259. #[arg(long, default_value = "authorize", value_parser = ["authorize", "post_logout"])]
  260. kind: String,
  261. },
  262. }
  263. /// `client` subcommands.
  264. #[derive(Debug, Subcommand)]
  265. pub enum ClientCommand {
  266. /// Attach a catalog redirect URI to a client.
  267. AttachRedirectUri {
  268. /// OAuth client identifier.
  269. #[arg(long)]
  270. client_id: String,
  271. /// Redirect URI from the catalog.
  272. #[arg(long)]
  273. uri: String,
  274. /// Catalog kind: `authorize` (default) or `post_logout`.
  275. #[arg(long, default_value = "authorize", value_parser = ["authorize", "post_logout"])]
  276. kind: String,
  277. },
  278. /// Detach a catalog redirect URI from a client.
  279. DetachRedirectUri {
  280. /// OAuth client identifier.
  281. #[arg(long)]
  282. client_id: String,
  283. /// Redirect URI.
  284. #[arg(long)]
  285. uri: String,
  286. /// Catalog kind: `authorize` (default) or `post_logout`.
  287. #[arg(long, default_value = "authorize", value_parser = ["authorize", "post_logout"])]
  288. kind: String,
  289. },
  290. /// List a client's attached redirect URIs.
  291. RedirectUris {
  292. /// OAuth client identifier.
  293. #[arg(long)]
  294. client_id: String,
  295. },
  296. }
  297. /// `config` subcommands.
  298. #[derive(Debug, Subcommand)]
  299. pub enum ConfigCommand {
  300. /// Apply a declarative file idempotently (scopes, redirect URIs, entitlements).
  301. Sync {
  302. /// JSON declarative file.
  303. #[arg(long, value_name = "PATH")]
  304. file: PathBuf,
  305. },
  306. /// Validate configuration; exits non-zero when invalid.
  307. Check,
  308. }
  309. /// `admin` subcommands.
  310. #[derive(Debug, Subcommand)]
  311. pub enum AdminCommand {
  312. /// Bootstrap the first admin. Refuses while any enabled admin exists
  313. /// unless `--force` is given (loud warning + audit event).
  314. Bootstrap {
  315. /// Admin email.
  316. #[arg(long)]
  317. email: String,
  318. /// Read the password from a file (else stdin; empty stdin generates one).
  319. #[arg(long, value_name = "PATH")]
  320. password_file: Option<PathBuf>,
  321. /// Create another admin even though one already exists (audited).
  322. #[arg(long)]
  323. force: bool,
  324. },
  325. }
  326. /// `totp` subcommands.
  327. #[derive(Debug, Subcommand)]
  328. pub enum TotpCommand {
  329. /// Re-wrap every v3 DEK under the active TOTP-encryption key.
  330. Rewrap,
  331. }
  332. /// `pii` subcommands.
  333. #[derive(Debug, Subcommand)]
  334. pub enum PiiCommand {
  335. /// Seal remaining plaintext PII and upgrade TOTP blobs to v3. Refuses while
  336. /// `pii_dual_write=true`; run only with the app tier scaled to zero.
  337. Backfill,
  338. /// Report the four cutover checks; exits non-zero when any is dirty.
  339. Verify,
  340. /// Re-encrypt sealed PII under the active key and recompute blind indexes.
  341. /// Run under a write freeze with the previous key retained; idempotent.
  342. Rotate,
  343. }
  344. /// `initial-access-token` subcommands.
  345. #[derive(Debug, Subcommand)]
  346. pub enum InitialAccessTokenCommand {
  347. /// Issue an initial-access token with a bounded use budget.
  348. Issue {
  349. /// Registration calls this token authorizes.
  350. #[arg(long)]
  351. uses: u64,
  352. /// Lifetime in seconds.
  353. #[arg(long)]
  354. ttl: u64,
  355. /// Operator label for audit (never a secret).
  356. #[arg(long, default_value = "cli")]
  357. label: String,
  358. },
  359. }
  360. /// Parse and execute the CLI, returning the process exit code.
  361. ///
  362. /// `--help`/`--version` print and return `0`; an unknown command prints usage
  363. /// to stderr and returns `2`; a command failure prints a clear message to
  364. /// stderr and returns `1`.
  365. pub async fn main_entry() -> i32 {
  366. let cli = match Cli::try_parse() {
  367. Ok(cli) => cli,
  368. Err(error) => {
  369. let code = if error.use_stderr() { 2 } else { 0 };
  370. let _ = error.print();
  371. return code;
  372. }
  373. };
  374. match run(cli).await {
  375. Ok(code) => code,
  376. Err(error) => {
  377. eprintln!("error: {error}");
  378. 1
  379. }
  380. }
  381. }
  382. /// Execute a parsed command.
  383. ///
  384. /// # Errors
  385. /// Returns [`CliError`] for any configuration, database, store, or I/O
  386. /// failure; the caller maps that to a non-zero exit code.
  387. pub async fn run(cli: Cli) -> Result<i32, CliError> {
  388. match cli.command {
  389. Command::Serve => {
  390. ops::serve().await?;
  391. Ok(0)
  392. }
  393. Command::Migrate => {
  394. ops::migrate().await?;
  395. Ok(0)
  396. }
  397. Command::User { command } => {
  398. let context = context::Context::from_env().await?;
  399. run_user(&context, command).await?;
  400. Ok(0)
  401. }
  402. Command::Scope { command } => {
  403. let context = context::Context::from_env().await?;
  404. scope::run(&context, command).await?;
  405. Ok(0)
  406. }
  407. Command::RedirectUri { command } => {
  408. let context = context::Context::from_env().await?;
  409. redirect::run_uri(&context, command).await?;
  410. Ok(0)
  411. }
  412. Command::Client { command } => {
  413. let context = context::Context::from_env().await?;
  414. redirect::run_client(&context, command).await?;
  415. Ok(0)
  416. }
  417. Command::Config { command } => match command {
  418. ConfigCommand::Check => {
  419. ops::config_check()?;
  420. Ok(0)
  421. }
  422. ConfigCommand::Sync { file } => {
  423. let report = sync::run(&file).await?;
  424. if report.is_noop() {
  425. println!("config sync: no changes");
  426. } else {
  427. println!("{report}");
  428. }
  429. Ok(0)
  430. }
  431. },
  432. Command::Admin { command } => {
  433. run_admin(command).await?;
  434. Ok(0)
  435. }
  436. Command::Totp { command } => {
  437. let context = context::Context::from_env().await?;
  438. run_totp(&context, command).await?;
  439. Ok(0)
  440. }
  441. Command::Pii { command } => {
  442. let context = context::Context::from_env().await?;
  443. run_pii(&context, command).await?;
  444. Ok(0)
  445. }
  446. Command::InitialAccessToken { command } => {
  447. run_initial_access_token(command).await?;
  448. Ok(0)
  449. }
  450. Command::Keygen { alg } => {
  451. ops::keygen(alg)?;
  452. Ok(0)
  453. }
  454. Command::Healthcheck { url } => ops::healthcheck(&url).await,
  455. }
  456. }
  457. async fn run_user(context: &context::Context, command: UserCommand) -> Result<(), CliError> {
  458. match command {
  459. UserCommand::Create {
  460. email,
  461. admin,
  462. password_stdin,
  463. password_file,
  464. } => {
  465. let password = context::read_secret(password_stdin, password_file.as_deref())?;
  466. user::create(context, &email, admin, &password).await
  467. }
  468. UserCommand::EnrollTotp { email } => {
  469. let enrollment = user::enroll_totp(context, &email).await?;
  470. println!("totp secret (shown once): {}", enrollment.secret);
  471. println!("otpauth uri: {}", enrollment.uri);
  472. Ok(())
  473. }
  474. UserCommand::Disable { email } => user::disable(context, &email).await,
  475. UserCommand::GrantScope { email, scope } => user::grant_scope(context, &email, &scope).await,
  476. UserCommand::RevokeScope { email, scope } => user::revoke_scope(context, &email, &scope).await,
  477. UserCommand::Scopes { email } => {
  478. let scopes = user::list_scopes(context, &email).await?;
  479. if scopes.is_empty() {
  480. println!("{email} has no scope entitlements");
  481. } else {
  482. for scope in scopes {
  483. println!("{scope}");
  484. }
  485. }
  486. Ok(())
  487. }
  488. }
  489. }
  490. async fn run_admin(command: AdminCommand) -> Result<(), CliError> {
  491. match command {
  492. AdminCommand::Bootstrap {
  493. email,
  494. password_file,
  495. force,
  496. } => ops::admin_bootstrap(&email, password_file.as_deref(), force).await,
  497. }
  498. }
  499. async fn run_totp(context: &context::Context, command: TotpCommand) -> Result<(), CliError> {
  500. match command {
  501. TotpCommand::Rewrap => {
  502. let report = totp::rewrap(context).await?;
  503. println!("{report}");
  504. Ok(())
  505. }
  506. }
  507. }
  508. async fn run_pii(context: &context::Context, command: PiiCommand) -> Result<(), CliError> {
  509. match command {
  510. PiiCommand::Backfill => {
  511. pii::backfill(context).await?;
  512. Ok(())
  513. }
  514. PiiCommand::Verify => {
  515. pii::verify(context).await?;
  516. Ok(())
  517. }
  518. PiiCommand::Rotate => {
  519. pii::rotate(context).await?;
  520. Ok(())
  521. }
  522. }
  523. }
  524. async fn run_initial_access_token(command: InitialAccessTokenCommand) -> Result<(), CliError> {
  525. match command {
  526. InitialAccessTokenCommand::Issue { uses, ttl, label } => {
  527. let token = ops::issue_initial_access_token(uses, ttl, &label).await?;
  528. println!("initial access token (shown once): {token}");
  529. Ok(())
  530. }
  531. }
  532. }