summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-17 21:48:00 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-17 21:48:00 +0200
commit071b7e9b01e41d4048b1bbb3d9caaac4c8a526de (patch)
treeb0af0a71e0d9fff2ca166d6ca69167ff32cc2ccb
parenta145684f0a556820a183db45d27e9b2dc715f056 (diff)
downloadbokf-0.1.3.tar.gz
bokf-0.1.3.zip
Add token login to bokfctl and enforce admin scope for tokensv0.1.3
- bokfctl: --token / BOKFD_TOKEN via client_token_login - tokens must carry the admin scope for admin commands (was bypassed) - docs: token-based snapshot for restic backups
-rw-r--r--clients/bokfctl.c23
-rw-r--r--clients/client.c49
-rw-r--r--clients/client.h4
-rw-r--r--docs/DEPLOY.md32
-rw-r--r--docs/PROTOCOL.md4
-rw-r--r--src/protocol.c3
-rw-r--r--tests/test_core.c30
7 files changed, 129 insertions, 16 deletions
diff --git a/clients/bokfctl.c b/clients/bokfctl.c
index 1f1372c..b5ecf47 100644
--- a/clients/bokfctl.c
+++ b/clients/bokfctl.c
@@ -17,6 +17,7 @@ static void usage(void)
" (env BOKFD_SOCKET, default /run/bokfd/bokfd.sock)\n"
" --user NAME login user (env BOKFD_USER)\n"
" --password PW login password (env BOKFD_PASSWORD)\n"
+ " --token TOKEN API token instead of user/password (env BOKFD_TOKEN)\n"
" --org ID active org for this request\n"
" --version\n"
"\n"
@@ -39,6 +40,7 @@ int main(int argc, char **argv)
target = "/run/bokfd/bokfd.sock";
const char *user = getenv("BOKFD_USER");
const char *password = getenv("BOKFD_PASSWORD");
+ const char *token = getenv("BOKFD_TOKEN");
int64_t org = 0;
const char *cmd = NULL;
const char *args_json = NULL;
@@ -70,6 +72,14 @@ int main(int argc, char **argv)
return 2;
}
password = val;
+ } else if (!strncmp(a, "--token", 7) &&
+ (a[7] == '\0' || a[7] == '=')) {
+ val = a[7] == '=' ? a + 8 : (i + 1 < argc ? argv[++i] : NULL);
+ if (!val) {
+ fprintf(stderr, "bokfctl: --token requires a value\n");
+ return 2;
+ }
+ token = val;
} else if (!strncmp(a, "--org", 5) &&
(a[5] == '\0' || a[5] == '=')) {
val = a[5] == '=' ? a + 6 : (i + 1 < argc ? argv[++i] : NULL);
@@ -111,14 +121,19 @@ int main(int argc, char **argv)
char *session = NULL;
if (!is_local_cmd(cmd)) {
- if (!user || !password) {
+ char *lerr = NULL;
+ int login_rc;
+ if (token && *token) {
+ login_rc = client_token_login(&conn, token, &session, &lerr);
+ } else if (user && password) {
+ login_rc = client_login(&conn, user, password, &session, &lerr);
+ } else {
fprintf(stderr,
- "bokfctl: set BOKFD_USER and BOKFD_PASSWORD (or --user/--password) to log in\n");
+ "bokfctl: set BOKFD_TOKEN or BOKFD_USER/BOKFD_PASSWORD (or --token/--user/--password) to log in\n");
client_close(&conn);
return 2;
}
- char *lerr = NULL;
- if (client_login(&conn, user, password, &session, &lerr) != 0) {
+ if (login_rc != 0) {
fprintf(stderr, "%s\n", lerr ? lerr : "login failed");
int rc = lerr && lerr[0] == '{' ? 1 : 2;
free(lerr);
diff --git a/clients/client.c b/clients/client.c
index 8fb604b..fd119f1 100644
--- a/clients/client.c
+++ b/clients/client.c
@@ -361,18 +361,12 @@ char *client_rpc(struct client_conn *c, const char *cmd, const char *session,
return client_read_line(c);
}
-int client_login(struct client_conn *c, const char *user, const char *password,
- char **session_out, char **err_out)
+static int session_open(struct client_conn *c, const char *args,
+ char **session_out, char **err_out)
{
*session_out = NULL;
*err_out = NULL;
- char *args = client_make_login_args(user, password);
- if (!args) {
- *err_out = xstrdup("could not build login request");
- return -1;
- }
char *resp = client_rpc(c, "session.open", NULL, 0, args);
- free(args);
if (!resp) {
*err_out = xstrdup(client_last_error());
return -1;
@@ -396,6 +390,45 @@ int client_login(struct client_conn *c, const char *user, const char *password,
return 0;
}
+int client_login(struct client_conn *c, const char *user, const char *password,
+ char **session_out, char **err_out)
+{
+ char *args = client_make_login_args(user, password);
+ if (!args) {
+ *session_out = NULL;
+ *err_out = xstrdup("could not build login request");
+ return -1;
+ }
+ int rc = session_open(c, args, session_out, err_out);
+ free(args);
+ return rc;
+}
+
+int client_token_login(struct client_conn *c, const char *token,
+ char **session_out, char **err_out)
+{
+ yyjson_mut_doc *d = yyjson_mut_doc_new(NULL);
+ if (!d) {
+ *session_out = NULL;
+ *err_out = xstrdup("out of memory");
+ return -1;
+ }
+ yyjson_mut_val *o = yyjson_mut_obj(d);
+ yyjson_mut_doc_set_root(d, o);
+ yyjson_mut_obj_add_strcpy(d, o, "method", "token");
+ yyjson_mut_obj_add_strcpy(d, o, "token", token);
+ char *args = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ if (!args) {
+ *session_out = NULL;
+ *err_out = xstrdup("could not build token request");
+ return -1;
+ }
+ int rc = session_open(c, args, session_out, err_out);
+ free(args);
+ return rc;
+}
+
int client_ok(const char *response)
{
if (!response)
diff --git a/clients/client.h b/clients/client.h
index 9372e72..40a8669 100644
--- a/clients/client.h
+++ b/clients/client.h
@@ -36,6 +36,10 @@ char *client_rpc(struct client_conn *c, const char *cmd, const char *session,
int client_login(struct client_conn *c, const char *user, const char *password,
char **session_out, char **err_out);
+/* Token login (same contract as client_login). */
+int client_token_login(struct client_conn *c, const char *token,
+ char **session_out, char **err_out);
+
/* Convenience: true when the response line has "ok":true. */
int client_ok(const char *response);
/* Extract result.session into buf; returns 0 on success. */
diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md
index 8d36453..eb3ec1c 100644
--- a/docs/DEPLOY.md
+++ b/docs/DEPLOY.md
@@ -170,12 +170,42 @@ Develop against this org; agents get their own API token
## Backup and restore
+`backup.snapshot` makes a consistent copy of the live WAL database with
+`VACUUM INTO`; the result lands in `var/db/backup/` as
+`bokfd-<timestamp>.db` (the response carries its SHA-256). Interactive:
+
```sh
docker compose exec -e BOKFD_PASSWORD='<pw>' bokfd \
bokfctl --user admin backup.snapshot
-ls var/db/backup # <db>-<timestamp>.db + .sha256
+ls var/db/backup
+```
+
+For unattended backups (restic, cron) use an admin-scoped token instead of
+the password, created once:
+
+```sh
+docker compose exec -e BOKFD_PASSWORD='<pw>' bokfd \
+ bokfctl --user admin token.create '{"label":"backup","scopes":["admin"]}'
+# store the token (shown once) in a root-only file, e.g. /etc/bokf/bokfd-backup.env:
+# BOKFD_TOKEN=bokf_...
```
+Then, before every restic run: take a snapshot through the daemon and let
+restic read only the snapshots — never the live database:
+
+```sh
+. /etc/bokf/bokfd-backup.env
+docker compose --project-directory /mnt/data/bokf exec -T -e BOKFD_TOKEN \
+ bokfd bokfctl backup.snapshot >/dev/null
+find /mnt/data/bokf/var/db/backup -name 'bokfd-*.db' -mtime +14 -delete
+restic backup --exclude='/mnt/data/bokf/var/db/bokfd.db*' \
+ --exclude='/mnt/data/bokf/var/run' /mnt/data/bokf
+```
+
+The WAL and shared-memory files (`bokfd.db-wal`, `bokfd.db-shm`) must be
+excluded together with the main file; an inconsistent copy of a live SQLite
+database is worse than no backup.
+
Restore:
```sh
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md
index e9c4331..25c9250 100644
--- a/docs/PROTOCOL.md
+++ b/docs/PROTOCOL.md
@@ -95,7 +95,9 @@ and returns an opaque, high-entropy session id:
- A token is bound to one user and one org, has scopes (`read`, `write`,
`admin`) and is an independent audit actor (label shown in history).
- Tokens are the intended mechanism for agents and for accountant/viewer
- access. They can be revoked immediately (`token.revoke`).
+ access. They can be revoked immediately (`token.revoke`). Scopes are
+ enforced for every command, including admin commands: `backup.snapshot`
+ and `user.*` need a token with the `admin` scope.
### 4.3 Roles and permissions
diff --git a/src/protocol.c b/src/protocol.c
index 7c00d6b..c528759 100644
--- a/src/protocol.c
+++ b/src/protocol.c
@@ -168,8 +168,7 @@ char *protocol_handle_line(sqlite3 *db, const char *line, size_t len)
add_error(rdoc, resp, "FORBIDDEN", "owner role required", NULL);
goto done;
}
- if (cmd->perm != PERM_ADMIN && scope &&
- !scope_has(r.sess->scopes, scope)) {
+ if (scope && !scope_has(r.sess->scopes, scope)) {
add_error(rdoc, resp, "FORBIDDEN",
"token scope does not allow this command", NULL);
goto done;
diff --git a/tests/test_core.c b/tests/test_core.c
index b2c828d..5960665 100644
--- a/tests/test_core.c
+++ b/tests/test_core.c
@@ -540,6 +540,12 @@ int main(void)
CHECK(db_exec(g_db, "DELETE FROM audit_log", &err) != 0);
free(err);
+ d = call(reqf("{\"v\":1,\"id\":\"27\",\"cmd\":\"backup.snapshot\","
+ "\"session\":\"%s\"}",
+ ro_session));
+ CHECK_STR(d, "error.code", "FORBIDDEN");
+ yyjson_doc_free(d);
+
d = call(reqf("{\"v\":1,\"id\":\"28\",\"cmd\":\"backup.snapshot\","
"\"session\":\"%s\"}",
g_session));
@@ -548,6 +554,30 @@ int main(void)
CHECK(jstr(d, "result.sha256") && strlen(jstr(d, "result.sha256")) == 64);
yyjson_doc_free(d);
+ d = call(reqf("{\"v\":1,\"id\":\"28b\",\"cmd\":\"token.create\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"label\":\"backup\",\"scopes\":[\"admin\"]}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ char backup_token[128] = "";
+ snprintf(backup_token, sizeof backup_token, "%s", jstr(d, "result.token"));
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"28c\",\"cmd\":\"session.open\",\"args\":"
+ "{\"method\":\"token\",\"token\":\"%s\"}}",
+ backup_token));
+ CHECK_OK(d);
+ char backup_session[128] = "";
+ snprintf(backup_session, sizeof backup_session, "%s",
+ jstr(d, "result.session"));
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"28d\",\"cmd\":\"backup.snapshot\","
+ "\"session\":\"%s\",\"args\":{\"dest\":\"%s/snap-token.db\"}}",
+ backup_session, tmpdir));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
d = call(reqf("{\"v\":1,\"id\":\"29\",\"cmd\":\"describe\","
"\"session\":\"%s\"}",
g_session));