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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ arcli logs --level warn --since 6h

The command tree also covers API tokens, continuous queries, schedulers, predicate deletes, backups, cluster membership, and compaction. Run `arcli --help` or `arcli <command> --help` for the complete command reference.

`db list`, `db show` and `measurement list` read Arc's database listing endpoints, which need a token carrying read permission. On a server that restricts reads per database, a token scoped to particular databases cannot list them all — Arc refuses rather than returning a filtered list, matching `SHOW DATABASES` — so `db list` reports that the token is scoped and asks you to name a database instead. Pass it to `arcli db show <name>` or `arcli measurement list --database <name>`.

## Output and automation

Commands support the formats appropriate to their API:
Expand Down
7 changes: 6 additions & 1 deletion docs/releases/v26.09.5.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
arcli 26.09.5 shows what a backup lacks, now that Arc 26.09.3 reports it on every backup endpoint.
arcli 26.09.5 shows what a backup lacks, now that Arc 26.09.3 reports it on every backup endpoint, and tells you which of Arc's two read refusals turned a database listing down.

### Changed

- `arcli backup list` gains an `INCOMPLETE` column: `-` when the server reports nothing missing, otherwise the counts the server keeps apart, such as `3 skipped, 1 metadata, 2 unaddressable` (skipped: data files inventoried but not stored; metadata: Iceberg metadata or compaction recovery state that was skipped; unaddressable: files whose key no listing can return, so they were never inventoried). The csv output appends three columns after `total_size_bytes`: `skipped_files`, `skipped_metadata_files`, `unaddressable_files`; scripts that take the last column by position should be updated. `-o json` is unchanged.
- `arcli backup show` names the files: its `INCOMPLETE` line now says how many files of the total and how many metadata files were skipped, how many of those were skipped for a key too long to store, how many files could not be listed, and how many files of an outside-root Iceberg warehouse were skipped, followed by one `skipped:` line per file the manifest names (up to 32) and one `unaddressable:` line per file in that sample.
- `arcli backup status`, and the final status that `arcli backup create --wait` and `arcli backup restore --wait` print, list the skipped files the server names and report files a backup could not list (their names are in the manifest once the backup has completed, otherwise in the server log). For a restore, failed or completed, they say what the restored backup already lacked when it was taken and how many Iceberg warehouse files were not restored because the node has no outside-root warehouse. `create --wait` ends its success line with every gap the server reports, prints the names, and points at `arcli backup show` for the full breakdown.
- `arcli backup restore` warns before restoring a backup that lacks files for any reason the manifest records (skipped data or metadata files, files that could not be listed, outside-root Iceberg warehouse files), not only skipped data files, and says how many of the skipped files had keys too long to store.
- `db list`, `db show` and `measurement list` report Arc's 401 and 403 answers as three separate conditions instead of one HTTP error. A 401 says whether the connection has no token at all or carries one Arc rejected; a 403 says whether the token lacks read permission outright or holds read but no grant for the database asked for. A 404 still says the database does not exist, so "you may not read it" and "it is not there" stay apart.
- A token scoped to particular databases cannot list all of them: Arc refuses `GET /api/v1/databases` rather than returning a filtered list, the same way `SHOW DATABASES` does. `arcli db list` now says the token is scoped and names the way forward — `arcli db show <database>`, `arcli measurement list --database <database>` — instead of printing a bare `HTTP 403`. That is an expected state for a tenant-scoped token, so arcli does not retry it.
- `arcli db list --help`, `arcli db show --help` and `arcli measurement list --help` state the permission each endpoint needs.

### Notes

- Against an Arc older than 26.09.3 every one of these reads as "none reported": `-` in the listing, no extra lines elsewhere. That is not a guarantee of completeness; it is what the server knew.
- Arc's three database listing endpoints previously carried no authentication at all. Nothing changes for an admin token, for a read token on a server that does not restrict reads per database, or for a server with authentication disabled.
- `client.AccessDeniedError` carries the status, the database asked for, whether the refusal was the per-database one, and whether the connection sent a token; `client.Scoped(err)` reports the scoped case for callers embedding the package.
164 changes: 164 additions & 0 deletions internal/client/access.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package client

import (
"errors"
"fmt"
"strings"
)

// AccessDeniedError is Arc's refusal of a read on one of the database
// listing routes — GET /api/v1/databases, /api/v1/databases/:name and
// /api/v1/databases/:name/measurements.
//
// Those three routes used to carry no authentication at all: any valid
// token, and on some deployments no token, could enumerate every
// database and measurement name on the server. They now require the
// read permission and, where the server restricts reads per database, a
// read grant covering the database being listed. So a 401 or 403 from
// them is new, and the 403s do not all mean the same thing:
//
// - Arc refuses a token whose grants do not cover the database asked
// for. That is a NORMAL state for a tenant-scoped token, not a
// failure: the caller has to name a database it does hold a grant
// for. Arc will not answer the list-everything route with a
// filtered list — a scoped caller must name its database, the same
// way `SHOW DATABASES` behaves — so a 403 there is the expected
// answer and must not be retried.
// - Arc refuses a token that does not carry the "read" permission at
// all. That needs a different token; naming a database will not
// help.
// - Arc fails closed when it cannot read its own permission data.
// That is a server-side fault, not a statement about the token.
//
// Those three are told apart by the message Arc sends, because the
// status code is 403 for all of them. See classifyAccessError for the
// shapes and for what an unrecognised one is reported as.
type AccessDeniedError struct {
// Status is 401 or 403 as Arc returned it.
Status int
// Database is the database the caller asked about, or
// listAllDatabases for the list-everything route.
Database string
// Scoped is true when Arc refused because the token's grants do not
// cover the database asked for — "name a database you are granted".
// Only meaningful for Status 403.
Scoped bool
// Unavailable is true when Arc could not read its own permission
// data and failed closed. Nothing about the token or the database
// caused it. Only meaningful for Status 403.
Unavailable bool
// HasToken records whether the connection sent a bearer token at
// all, so a 401 on a token-less connection can say so instead of
// quoting a bare status code.
HasToken bool
// Message is Arc's own message, already control-scrubbed by
// decodeWriteError.
Message string
}

// listAllDatabases is the pseudo-name the server uses for the
// list-everything route's permission check, and the value Database
// carries for it.
const listAllDatabases = "*"

func (e *AccessDeniedError) Error() string {
switch {
case e.Status == 401 && !e.HasToken:
return "Arc requires a token to list databases but this connection has none " +
"(add one with `arcli config update NAME --token ...`)"
case e.Status == 401:
return fmt.Sprintf("Arc rejected this connection's token (%s); "+
"it may be invalid, expired or revoked — issue a new one and "+
"`arcli config update NAME --token ...`", e.Message)
case e.Unavailable:
return fmt.Sprintf("arc could not read its own permission data and refused the "+
"request rather than guessing (%s); this is a server-side fault, not a "+
"problem with this token — check the server log", e.Message)
case e.Scoped && e.Database == listAllDatabases:
return "this token is scoped to specific databases, so Arc will not list them all; " +
"name a database you are granted (`arcli db show <database>`, " +
"`arcli measurement list --database <database>`)"
case e.Scoped:
return fmt.Sprintf("this token has no read grant for database %q; "+
"name a database it is granted, or ask an Arc administrator to grant it",
e.Database)
default:
return fmt.Sprintf("this token does not carry the read permission Arc requires "+
"to list databases (%s); use a token with read permission", e.Message)
}
}

// Scoped reports whether err is Arc's per-database RBAC denial — the
// "you are scoped, name your database" answer — rather than a missing
// read permission or a bad token. Exposed so a caller can treat that
// case as a normal, expected state.
func Scoped(err error) bool {
var ae *AccessDeniedError
return errors.As(err, &ae) && ae.Scoped
}

// classifyAccessError maps the 401/403 the database listing routes can
// answer with onto *AccessDeniedError and leaves every other error
// untouched. database is the name asked about, or listAllDatabases for
// the list-everything route.
//
// Arc sends 403 for three different things and distinguishes them only
// by message, so the message is what this reads. The shapes, all from
// the read middleware and the handler gate on these routes:
//
// no permission for read on database 'x' -> scoped
// access denied: no read permission for database 'x' -> scoped
// permission data unavailable -> unavailable
// token does not have 'read' permission -> coarse
// Permission denied: read required -> coarse
//
// Matching is by stable fragment rather than whole string, so a change
// to the permission word or to surrounding punctuation does not
// silently reclassify one for another. An unrecognised 403 is reported
// as the coarse case on purpose: naming the token's permissions is a
// safe thing to say about any refusal, whereas telling an operator to
// pass a database they may already have passed is not.
func (c *Client) classifyAccessError(err error, database string) error {
var he *HTTPError
if !errors.As(err, &he) {
return err
}
if he.Status != 401 && he.Status != 403 {
return err
}
msg := he.Message
if msg == "" {
msg = he.Raw
}
return &AccessDeniedError{
Status: he.Status,
Database: database,
Scoped: he.Status == 403 && isScopedDenial(msg),
Unavailable: he.Status == 403 && isPermissionDataUnavailable(msg),
HasToken: c.HasToken(),
Message: msg,
}
}

// isScopedDenial recognises the two bodies that mean "your grants do not
// cover that database": the read middleware's
// "no permission for read on database 'x'" and the handler gate's
// "access denied: no read permission for database 'x'".
func isScopedDenial(msg string) bool {
m := strings.ToLower(strings.TrimSpace(msg))
switch {
case strings.HasPrefix(m, "no permission for ") && strings.Contains(m, " on database "):
return true
case strings.HasPrefix(m, "access denied: no ") && strings.Contains(m, "permission for database"):
return true
default:
return false
}
}

// isPermissionDataUnavailable recognises Arc's fail-closed answer when
// it could not load the token's grants. It is a 403 like the others but
// says nothing about the token, so it must not be reported as one.
func isPermissionDataUnavailable(msg string) bool {
return strings.Contains(strings.ToLower(msg), "permission data unavailable")
}
Loading
Loading