anvilsign in

collin/anvil · 91f548f3

feat: read-only personal access tokens (PATs) for API fetches

Collin Richards · 2026-06-10 14:27 UTC · 91f548f3896bf4aa6e26dd840b646683c4157526 · parent 680a0d93 · browse files

modifiedCLAUDE.md+18 −0
⋯ 16 unchanged lines
1717 `cargo sort-derives` (`cargo install cargo-sort-derives`), and
1818 `cargo clippy --workspace --all-targets -- -D warnings`.
1919
20+## Viewing attachments referenced in tasks
21+
22+TODO items and tickets may embed an uploaded image as
23+`![...](/{owner}/{repo}/-/attachments/{hash})`. These bytes live outside git on
24+the anvil instance, so to actually *see* one, fetch it from the instance and
25+`Read` the file. With `ANVIL_BASE_URL` (the instance URL) and `ANVIL_TOKEN` (a
26+read-only PAT — mint one with `anvild user token create <user>`) set:
27+
28+```
29+curl -fsS -H "Authorization: Bearer $ANVIL_TOKEN" \
30+ "$ANVIL_BASE_URL/{owner}/{repo}/-/attachments/{hash}" -o /tmp/att.png
31+```
32+
33+Then `Read /tmp/att.png` to view it. The PAT is read-only (it authenticates
34+GET/HEAD only), so it's safe to hold; public-repo attachments need no token.
35+If those env vars aren't set, ask the user for the instance URL and a token
36+rather than guessing.
37+
2038 Current status, resume notes, the agreed next steps (a/b/c), and the roadmap live
2139 in the TODO. Read it first:
2240
⋯ 1 unchanged line
modifiedTODO.md+11 −0
⋯ 58 unchanged lines
5959 - [ ] maybe: per-repo drill-down, and a cheap cached/periodic variant if the
6060 on-demand disk walk gets slow on large instances.
6161
62+## API tokens (read-only PATs)
63+
64+- [x] `ApiToken` model + `anvil-core::api_tokens` (create/list/revoke, SHA-256
65+ hashed, scoped). CLI `anvild user token create|list|revoke`.
66+- [x] bearer auth: `CurrentUser` also accepts `Authorization: Bearer <pat>` on
67+ GET/HEAD only — least-privilege read-only (writes need a session CSRF a bearer
68+ lacks). Lets tooling (and Claude) fetch private-repo attachments over HTTP.
69+ See the recipe in `CLAUDE.md`.
70+- [ ] maybe later: a `write` scope (would need CSRF-exempt write paths), token
71+ management in the web UI, and `last_used_at` tracking.
72+
6273 ## UI polish (done)
6374
6475 - [x] less vertical padding at the top of the screen (`main` top padding 24→12px)
⋯ 5 unchanged lines
modifiedcrates/anvil-cli/src/main.rs+56 −0
⋯ 2 unchanged lines
33 use anvil_core::{
44 App,
55 Config,
6+ api_tokens,
67 repos,
78 ssh_keys,
89 users,
⋯ 64 unchanged lines
7374 #[arg(long, default_value = "")]
7475 title: String,
7576 },
77+ /// Manage personal access tokens (read-only API bearer credentials).
78+ Token {
79+ #[command(subcommand)]
80+ command: TokenCommand,
81+ },
82+}
83+
84+#[derive(Subcommand)]
85+enum TokenCommand {
86+ /// Mint a token for a user. The plaintext is printed once — store it now.
87+ Create {
88+ username: String,
89+ /// Human label for the token (shown when listing).
90+ #[arg(long, default_value = "api")]
91+ name: String,
92+ },
93+ /// List a user's tokens (id, name, created — never the secret).
94+ List { username: String },
95+ /// Revoke a token by id.
96+ Revoke { id: i64 },
7697 }
7798
7899 #[derive(Subcommand)]
⋯ 97 unchanged lines
176197 user.username, saved.fingerprint
177198 );
178199 }
200+ UserCommand::Token { command } => token(&app, command).await?,
201+ }
202+ Ok(())
203+}
204+
205+async fn token(app: &App, command: TokenCommand) -> Result<()> {
206+ match command {
207+ TokenCommand::Create { username, name } => {
208+ let user = users::find_by_username(&app.db, &username)
209+ .await?
210+ .with_context(|| format!("no such user: {username}"))?;
211+ let (_, plaintext) =
212+ api_tokens::create(&app.db, user.id, &name, api_tokens::READ).await?;
213+ println!("created read-only token '{name}' for {username}.");
214+ println!("store this now — it won't be shown again:\n\n {plaintext}\n");
215+ }
216+ TokenCommand::List { username } => {
217+ let user = users::find_by_username(&app.db, &username)
218+ .await?
219+ .with_context(|| format!("no such user: {username}"))?;
220+ let tokens = api_tokens::list(&app.db, user.id).await?;
221+ if tokens.is_empty() {
222+ println!("{username} has no tokens.");
223+ }
224+ for t in tokens {
225+ println!("#{} {} [{}]", t.id, t.name, t.scopes);
226+ }
227+ }
228+ TokenCommand::Revoke { id } => {
229+ if api_tokens::revoke(&app.db, id).await? {
230+ println!("revoked token #{id}.");
231+ } else {
232+ anyhow::bail!("no token with id {id}");
233+ }
234+ }
179235 }
180236 Ok(())
181237 }
⋯ 40 unchanged lines
addedcrates/anvil-core/src/api_tokens.rs+144 −0
1+//! Personal access tokens: scoped, least-privilege bearer credentials for
2+//! non-browser API clients.
3+//!
4+//! Tokens look like `anvil_pat_<64 hex>`. Only their SHA-256 hash is persisted;
5+//! the plaintext is returned once at creation and never recoverable. Scopes
6+//! bound what a token may do — today only [`READ`], which the web layer honors
7+//! by accepting bearer auth on safe (GET/HEAD) requests only.
8+
9+use sha2::{
10+ Digest,
11+ Sha256,
12+};
13+
14+use crate::{
15+ error::Result,
16+ models::{
17+ ApiToken,
18+ User,
19+ },
20+ users,
21+};
22+
23+/// Read-only scope: authenticates safe requests, can never mutate.
24+pub const READ: &str = "read";
25+
26+/// The token plaintext prefix, so a token is recognizable on sight and bearer
27+/// auth can cheaply reject non-PAT strings.
28+const PREFIX: &str = "anvil_pat_";
29+
30+/// Lowercase hex SHA-256 of a token's plaintext — its lookup key.
31+fn hash(token: &str) -> String {
32+ Sha256::digest(token.as_bytes())
33+ .iter()
34+ .map(|b| format!("{b:02x}"))
35+ .collect()
36+}
37+
38+/// Whether `token` grants `scope`.
39+pub fn has_scope(token: &ApiToken, scope: &str) -> bool {
40+ token.scopes.split(',').any(|s| s.trim() == scope)
41+}
42+
43+/// Mint a token for `user_id` with the given comma-separated `scopes`,
44+/// returning the stored row and the one-time plaintext to show the user.
45+pub async fn create(
46+ db: &toasty::Db,
47+ user_id: i64,
48+ name: &str,
49+ scopes: &str,
50+) -> Result<(ApiToken, String)> {
51+ use argon2::password_hash::rand_core::{
52+ OsRng,
53+ RngCore,
54+ };
55+ let mut raw = [0u8; 32];
56+ OsRng.fill_bytes(&mut raw);
57+ let plaintext = format!(
58+ "{PREFIX}{}",
59+ raw.iter().map(|b| format!("{b:02x}")).collect::<String>()
60+ );
61+
62+ let mut conn = db.clone();
63+ let row = toasty::create!(ApiToken {
64+ user_id: user_id,
65+ name: name,
66+ token_hash: hash(&plaintext),
67+ scopes: scopes,
68+ created_at: crate::now(),
69+ })
70+ .exec(&mut conn)
71+ .await?;
72+ Ok((row, plaintext))
73+}
74+
75+/// Look up the token row for a presented plaintext, if any.
76+pub async fn lookup(db: &toasty::Db, token: &str) -> Result<Option<ApiToken>> {
77+ if !token.starts_with(PREFIX) {
78+ return Ok(None);
79+ }
80+ let mut conn = db.clone();
81+ let row = ApiToken::filter(ApiToken::fields().token_hash().eq(hash(token)))
82+ .first()
83+ .exec(&mut conn)
84+ .await?;
85+ Ok(row)
86+}
87+
88+/// Resolve the [`User`] a token authenticates, if the token is valid.
89+pub async fn lookup_user(db: &toasty::Db, token: &str) -> Result<Option<User>> {
90+ match lookup(db, token).await? {
91+ Some(row) => users::find_by_id(db, row.user_id).await,
92+ None => Ok(None),
93+ }
94+}
95+
96+/// A user's tokens, newest first (hashes only — plaintext is unrecoverable).
97+pub async fn list(db: &toasty::Db, user_id: i64) -> Result<Vec<ApiToken>> {
98+ let mut conn = db.clone();
99+ let rows = ApiToken::filter(ApiToken::fields().user_id().eq(user_id))
100+ .order_by(ApiToken::fields().id().desc())
101+ .exec(&mut conn)
102+ .await?;
103+ Ok(rows)
104+}
105+
106+/// Revoke (delete) a token by id. Returns whether a row was removed.
107+pub async fn revoke(db: &toasty::Db, id: i64) -> Result<bool> {
108+ let mut conn = db.clone();
109+ let Some(row) = ApiToken::filter(ApiToken::fields().id().eq(id))
110+ .first()
111+ .exec(&mut conn)
112+ .await?
113+ else {
114+ return Ok(false);
115+ };
116+ row.delete().exec(&mut conn).await?;
117+ Ok(true)
118+}
119+
120+#[cfg(test)]
121+mod tests {
122+ use super::*;
123+
124+ #[test]
125+ fn non_pat_strings_are_rejected_before_any_lookup() {
126+ // lookup() must short-circuit non-prefixed input — assert via has_scope
127+ // and hashing being deterministic here; the prefix guard is in lookup().
128+ assert!(!"some-session-token".starts_with(PREFIX));
129+ }
130+
131+ #[test]
132+ fn scope_membership() {
133+ let tok = ApiToken {
134+ id: 1,
135+ user_id: 1,
136+ name: "t".into(),
137+ token_hash: "h".into(),
138+ scopes: "read".into(),
139+ created_at: 0,
140+ };
141+ assert!(has_scope(&tok, READ));
142+ assert!(!has_scope(&tok, "write"));
143+ }
144+}
modifiedcrates/anvil-core/src/db.rs+21 −2
⋯ 4 unchanged lines
55 use crate::{
66 error::Result,
77 models::{
8+ ApiToken,
89 Attachment,
910 CiArtifact,
1011 CiRun,
⋯ 30 unchanged lines
4142 CiArtifact,
4243 Issue,
4344 IssueComment,
44- Attachment
45+ Attachment,
46+ ApiToken
4547 ))
4648 .connect(&url)
4749 .await?;
⋯ 20 unchanged lines
6870 r#"CREATE INDEX IF NOT EXISTS "index_issue_comments_by_issue_id" ON "issue_comments" ("issue_id")"#,
6971 ATTACHMENTS_DDL,
7072 r#"CREATE INDEX IF NOT EXISTS "index_attachments_by_repo_id" ON "attachments" ("repo_id")"#,
73+ API_TOKENS_DDL,
74+ r#"CREATE INDEX IF NOT EXISTS "index_api_tokens_by_user_id" ON "api_tokens" ("user_id")"#,
75+ r#"CREATE UNIQUE INDEX IF NOT EXISTS "index_api_tokens_by_token_hash" ON "api_tokens" ("token_hash")"#,
7176 ];
7277
7378 const CI_ARTIFACTS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "ci_artifacts" (
⋯ 35 unchanged lines
109114 "uploader_id" BIGINT NOT NULL,
110115 "created_at" BIGINT NOT NULL )"#;
111116
117+const API_TOKENS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "api_tokens" (
118+"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
119+"user_id" BIGINT NOT NULL,
120+"name" TEXT NOT NULL,
121+"token_hash" TEXT NOT NULL,
122+"scopes" TEXT NOT NULL,
123+"created_at" BIGINT NOT NULL )"#;
124+
112125 /// Columns added to existing tables after deployment, applied as
113126 /// `ALTER TABLE … ADD COLUMN` when missing (SQLite has no `IF NOT EXISTS`
114127 /// for columns, so presence is checked via `pragma_table_info`). The model
⋯ 36 unchanged lines
151164 use super::*;
152165
153166 /// Tables created by shims (i.e. added after the first deployment).
154- const SHIMMED_TABLES: &[&str] = &["ci_artifacts", "issues", "issue_comments", "attachments"];
167+ const SHIMMED_TABLES: &[&str] = &[
168+ "ci_artifacts",
169+ "issues",
170+ "issue_comments",
171+ "attachments",
172+ "api_tokens",
173+ ];
155174
156175 /// Every schema object (table + indexes) for `table`, normalized.
157176 fn schema_objects(path: &Path, table: &str) -> Vec<String> {
⋯ 117 unchanged lines
modifiedcrates/anvil-core/src/lib.rs+2 −0
⋯ 5 unchanged lines
66 //! `anvil-git`) build on top of it.
77
88 pub mod access;
9+pub mod api_tokens;
910 pub mod attachments;
1011 pub mod ci;
1112 pub mod config;
⋯ 14 unchanged lines
2627 Result,
2728 };
2829 pub use models::{
30+ ApiToken,
2931 Attachment,
3032 CiArtifact,
3133 CiRun,
⋯ 101 unchanged lines
modifiedcrates/anvil-core/src/models.rs+23 −0
⋯ 168 unchanged lines
169169 pub created_at: i64,
170170 }
171171
172+/// A personal access token: a long-lived, scoped bearer credential for
173+/// non-browser API clients (e.g. tooling that fetches attachments). Only the
174+/// SHA-256 hash of the token is stored; the plaintext is shown once at
175+/// creation. A PAT is least-privilege by design — its `scopes` bound what it
176+/// can do, and the only scope today (`read`) authenticates safe (GET/HEAD)
177+/// requests only, so a leaked token can never mutate.
178+#[derive(Clone, Debug, toasty::Model)]
179+pub struct ApiToken {
180+ #[key]
181+ #[auto]
182+ pub id: i64,
183+ #[index]
184+ pub user_id: i64,
185+ /// A human label for the token (e.g. "claude"), for listing/revoking.
186+ pub name: String,
187+ /// Lowercase hex SHA-256 of the token; the lookup key.
188+ #[unique]
189+ pub token_hash: String,
190+ /// Comma-separated scopes granted to this token (e.g. `read`).
191+ pub scopes: String,
192+ pub created_at: i64,
193+}
194+
172195 /// A registered SSH public key, used to authenticate git-over-SSH connections.
173196 #[derive(Debug, toasty::Model)]
174197 pub struct SshKey {
⋯ 13 unchanged lines
modifiedcrates/anvil-web/src/auth.rs+33 −3
⋯ 5 unchanged lines
66 use anvil_core::{
77 App,
88 User,
9+ api_tokens,
910 sessions,
1011 users,
1112 };
⋯ 5 unchanged lines
1718 State,
1819 },
1920 http::{
21+ Method,
2022 StatusCode,
2123 request::Parts,
2224 },
⋯ 44 unchanged lines
6769 CSRF_TOKEN.scope(token, next.run(req)).await
6870 }
6971
70-/// Extractor yielding the logged-in user, if any, from the session cookie.
71-/// Never fails — absence of a valid session simply yields `None`.
72+/// Extractor yielding the logged-in user, if any. Authenticates from the
73+/// session cookie, or — on safe (GET/HEAD) requests only — from a
74+/// `Authorization: Bearer <pat>` personal access token. Never fails: absence
75+/// of a valid credential simply yields `None`.
76+///
77+/// PAT auth is deliberately confined to read methods: a token grants the
78+/// `read` scope and nothing more, so a leaked token can never mutate (and
79+/// mutating handlers also require a session-bound CSRF token a bearer lacks).
7280 pub struct CurrentUser(pub Option<User>);
7381
7482 impl FromRequestParts<App> for CurrentUser {
⋯ 1 unchanged line
7684
7785 async fn from_request_parts(parts: &mut Parts, app: &App) -> Result<Self, Infallible> {
7886 let jar = CookieJar::from_headers(&parts.headers);
79- let user = match jar.get(SESSION_COOKIE) {
87+ let mut user = match jar.get(SESSION_COOKIE) {
8088 Some(cookie) => sessions::lookup_user(&app.db, cookie.value())
8189 .await
8290 .ok()
8391 .flatten(),
8492 None => None,
8593 };
94+
95+ // Read-only PAT fallback for API clients (no session cookie).
96+ let safe = parts.method == Method::GET || parts.method == Method::HEAD;
97+ if user.is_none()
98+ && safe
99+ && let Some(token) = bearer_token(&parts.headers)
100+ && let Ok(Some(tok)) = api_tokens::lookup(&app.db, token).await
101+ && api_tokens::has_scope(&tok, api_tokens::READ)
102+ {
103+ user = users::find_by_id(&app.db, tok.user_id).await.ok().flatten();
104+ }
105+
86106 Ok(CurrentUser(user))
87107 }
88108 }
89109
110+/// Extract the credential from an `Authorization: Bearer <token>` header.
111+fn bearer_token(headers: &axum::http::HeaderMap) -> Option<&str> {
112+ headers
113+ .get(axum::http::header::AUTHORIZATION)?
114+ .to_str()
115+ .ok()?
116+ .strip_prefix("Bearer ")
117+ .map(str::trim)
118+}
119+
90120 /// Extractor yielding the CSRF token bound to the caller's session, or an empty
91121 /// string when unauthenticated. Embed it in forms via [`crate::ui::csrf_input`]
92122 /// and verify mutating POSTs with [`verify_csrf`].
⋯ 151 unchanged lines