P4.3: token security at rest + dist token CLI
Subplan 02 Slice 5: Token domain entity (scopes, app/channel scoping, expiry, revocation, last-used) with boundary validation; secrets are dist_<64 hex> drawn from arc4random_buf and only their SHA-256 is persisted. check_token gates revocation > expiry > scope > app/channel; mark_token_used stamps usage for the P4.4 server auth. CLI: dist token create (raw secret shown exactly once; works on a fresh store so CI tokens can predate the first publish), list (lifecycle status, never the secret), revoke (unknown id and double-revoke are distinct errors). Every mutation appends an audit event; tokens joins db.json's persisted arrays, with an absent member loading as empty so older db.json files stay readable. make test 16/16 (new: token_check.sx unit suite, token_ops.sx pinned CLI acceptance).
This commit is contained in:
136
src/dist.sx
136
src/dist.sx
@@ -8,6 +8,9 @@
|
||||
// dist release promote point a channel at a release (P3.5) — see release/ops.sx
|
||||
// dist release rollback channel pointer to the previous release (P3.5)
|
||||
// dist server run read-only HTTP API over the store (P4.1) — see server/distd.sx
|
||||
// dist token create mint a scoped CI token, secret shown once (P4.3) — see token/ops.sx
|
||||
// dist token list tokens with lifecycle status, never the secret (P4.3)
|
||||
// dist token revoke revoke a token by id (P4.3)
|
||||
//
|
||||
// EXIT-CODE CONTRACT (sysexits, via std.cli): success ends with
|
||||
// `exit_ok()` (EX_OK = 0); a no-command / unknown-or-missing
|
||||
@@ -30,6 +33,7 @@
|
||||
jout :: #import "json_out.sx";
|
||||
pl :: #import "publish/publish.sx";
|
||||
ops :: #import "release/ops.sx";
|
||||
tops :: #import "token/ops.sx";
|
||||
srv :: #import "server/distd.sx";
|
||||
|
||||
// Direct stderr writer (fd 2), so human help/usage/progress never lands on
|
||||
@@ -45,7 +49,7 @@ emit_human :: (s: string, json_mode: bool) {
|
||||
if json_mode { eputs(s); } else { out(s); }
|
||||
}
|
||||
|
||||
HELP :: "dist — application distribution CLI\n\nUsage:\n dist <group> <command> [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI\n --manifest <path> publish manifest (dist.json) to read\n --local-store <dir> local artifact store + db.json directory\n release\n release promote point a channel at a release\n --app <slug> app the channel belongs to\n --channel <name> channel to move\n --release <id> release id to promote\n --local-store <dir> local artifact store + db.json directory\n release rollback move a channel back to its previous release\n --app <slug> app the channel belongs to\n --channel <name> channel to roll back\n --local-store <dir> local artifact store + db.json directory\n server\n server run serve the store read-only over HTTP (0.0.0.0)\n --local-store <dir> local artifact store + db.json directory\n --port <n> TCP port (default 8787)\n routes: / (HTML index), /healthz, /api/apps, /api/apps/<slug>, /download/<sha256>\n\nGlobal flags:\n --json emit machine-readable JSON on stdout; human text to stderr\n -h, --help show this help and exit\n\nExit codes:\n 0 success\n 1 command failed (publish/promote/rollback aborted or server could not bind)\n 64 usage error (no command, or an unknown/missing command or flag)\n";
|
||||
HELP :: "dist — application distribution CLI\n\nUsage:\n dist <group> <command> [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI\n --manifest <path> publish manifest (dist.json) to read\n --local-store <dir> local artifact store + db.json directory\n release\n release promote point a channel at a release\n --app <slug> app the channel belongs to\n --channel <name> channel to move\n --release <id> release id to promote\n --local-store <dir> local artifact store + db.json directory\n release rollback move a channel back to its previous release\n --app <slug> app the channel belongs to\n --channel <name> channel to roll back\n --local-store <dir> local artifact store + db.json directory\n server\n server run serve the store read-only over HTTP (0.0.0.0)\n --local-store <dir> local artifact store + db.json directory\n --port <n> TCP port (default 8787)\n routes: / (HTML index), /healthz, /api/apps, /api/apps/<slug>, /download/<sha256>\n token\n token create mint a scoped automation token (secret shown ONCE)\n --name <name> token name, [a-z0-9._-]\n --local-store <dir> local artifact store + db.json directory\n --scope <words> space-separated scopes: publish read (default: publish)\n --app <slug> restrict to one app (default: any)\n --channel <name> restrict to one channel (default: any)\n --expires-in <secs> lifetime in seconds (default: never expires)\n token list tokens with lifecycle status (never the secret)\n --local-store <dir> local artifact store + db.json directory\n token revoke revoke a token by id\n --id <token-id> token to revoke\n --local-store <dir> local artifact store + db.json directory\n\nGlobal flags:\n --json emit machine-readable JSON on stdout; human text to stderr\n -h, --help show this help and exit\n\nExit codes:\n 0 success\n 1 command failed (publish/promote/rollback/token op aborted or server could not bind)\n 64 usage error (no command, or an unknown/missing command or flag)\n";
|
||||
|
||||
// True if `name` appears as a token in `args`.
|
||||
has_flag :: (args: []string, name: string) -> bool {
|
||||
@@ -208,6 +212,115 @@ handle_server_run :: (p: *Parsed, json_mode: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// `dist token create` — mint a scoped token over the persisted store
|
||||
// (P4.3). The raw secret appears in this command's output and NOWHERE
|
||||
// else; only its hash is persisted. Rendering contract as above.
|
||||
handle_token_create :: (p: *Parsed, json_mode: bool) {
|
||||
expires_in : i64 = 0;
|
||||
if p.is_set("expires-in") {
|
||||
eq := parse_secs(p.value_of("expires-in"));
|
||||
if eq == null {
|
||||
eputs(concat(concat("dist: --expires-in must be a positive number of seconds, got: ", p.value_of("expires-in")), "\n"));
|
||||
exit_usage();
|
||||
}
|
||||
expires_in = eq!;
|
||||
}
|
||||
scopes := if p.is_set("scope") then p.value_of("scope") else "";
|
||||
app_slug := if p.is_set("app") then p.value_of("app") else "";
|
||||
channel := if p.is_set("channel") then p.value_of("channel") else "";
|
||||
|
||||
fail : jout.CliFailure = .{};
|
||||
o, e := tops.run_token_create(p.value_of("local-store"), p.value_of("name"),
|
||||
scopes, app_slug, channel, expires_in, @fail);
|
||||
if e {
|
||||
report_failure("token create", fail, json_mode);
|
||||
}
|
||||
if !e {
|
||||
if !json_mode {
|
||||
out(tops.token_create_human(@o));
|
||||
return;
|
||||
}
|
||||
eputs("dist: token create ok (secret shown once)\n");
|
||||
raw : [4096]u8 = ---;
|
||||
werr := false;
|
||||
n := tops.write_token_create_json(@o, string.{ ptr = @raw[0], len = 4096 }) catch { werr = true; 0 };
|
||||
if werr {
|
||||
eputs("dist: internal error: JSON serialization failed\n");
|
||||
exit_command_failed();
|
||||
}
|
||||
out(string.{ ptr = @raw[0], len = n });
|
||||
out("\n");
|
||||
}
|
||||
}
|
||||
|
||||
// `dist token list` — every token with its lifecycle status; secrets and
|
||||
// hashes never appear. Rendering contract as above.
|
||||
handle_token_list :: (p: *Parsed, json_mode: bool) {
|
||||
fail : jout.CliFailure = .{};
|
||||
o, e := tops.run_token_list(p.value_of("local-store"), @fail);
|
||||
if e {
|
||||
report_failure("token list", fail, json_mode);
|
||||
}
|
||||
if !e {
|
||||
if !json_mode {
|
||||
out(tops.token_list_human(@o));
|
||||
return;
|
||||
}
|
||||
eputs("dist: token list ok\n");
|
||||
raw : [16384]u8 = ---;
|
||||
werr := false;
|
||||
n := tops.write_token_list_json(@o, string.{ ptr = @raw[0], len = 16384 }) catch { werr = true; 0 };
|
||||
if werr {
|
||||
eputs("dist: internal error: JSON serialization failed\n");
|
||||
exit_command_failed();
|
||||
}
|
||||
out(string.{ ptr = @raw[0], len = n });
|
||||
out("\n");
|
||||
}
|
||||
}
|
||||
|
||||
// `dist token revoke` — revoke by id. Rendering contract as above.
|
||||
handle_token_revoke :: (p: *Parsed, json_mode: bool) {
|
||||
fail : jout.CliFailure = .{};
|
||||
o, e := tops.run_token_revoke(p.value_of("local-store"), p.value_of("id"), @fail);
|
||||
if e {
|
||||
report_failure("token revoke", fail, json_mode);
|
||||
}
|
||||
if !e {
|
||||
if !json_mode {
|
||||
out(tops.token_revoke_human(@o));
|
||||
return;
|
||||
}
|
||||
eputs("dist: token revoke ok\n");
|
||||
raw : [4096]u8 = ---;
|
||||
werr := false;
|
||||
n := tops.write_token_revoke_json(@o, string.{ ptr = @raw[0], len = 4096 }) catch { werr = true; 0 };
|
||||
if werr {
|
||||
eputs("dist: internal error: JSON serialization failed\n");
|
||||
exit_command_failed();
|
||||
}
|
||||
out(string.{ ptr = @raw[0], len = n });
|
||||
out("\n");
|
||||
}
|
||||
}
|
||||
|
||||
// Positive decimal seconds (a token lifetime); anything else (empty,
|
||||
// non-digits, zero, absurd length) is null — a usage error at the call
|
||||
// site.
|
||||
parse_secs :: (s: string) -> ?i64 {
|
||||
if s.len == 0 or s.len > 12 { return null; }
|
||||
v : i64 = 0;
|
||||
i := 0;
|
||||
while i < s.len {
|
||||
c := s[i];
|
||||
if c < 48 or c > 57 { return null; } // '0'..'9'
|
||||
v = v * 10 + (c - 48);
|
||||
i += 1;
|
||||
}
|
||||
if v < 1 { return null; }
|
||||
return v;
|
||||
}
|
||||
|
||||
// Decimal port in 1..65535; anything else (empty, non-digits, out of
|
||||
// range) is null — a usage error at the call site.
|
||||
parse_port :: (s: string) -> ?i64 {
|
||||
@@ -231,6 +344,9 @@ dispatch :: (p: *Parsed, json_mode: bool) {
|
||||
if p.group == "release" and p.command == "promote" { handle_release_promote(p, json_mode); return; }
|
||||
if p.group == "release" and p.command == "rollback" { handle_release_rollback(p, json_mode); return; }
|
||||
if p.group == "server" and p.command == "run" { handle_server_run(p, json_mode); return; }
|
||||
if p.group == "token" and p.command == "create" { handle_token_create(p, json_mode); return; }
|
||||
if p.group == "token" and p.command == "list" { handle_token_list(p, json_mode); return; }
|
||||
if p.group == "token" and p.command == "revoke" { handle_token_revoke(p, json_mode); return; }
|
||||
eputs("dist: internal error: unrouted command\n");
|
||||
exit_usage();
|
||||
}
|
||||
@@ -280,11 +396,29 @@ main :: () -> ! {
|
||||
FlagSpec.{ name = "local-store", takes_value = true, required = true },
|
||||
FlagSpec.{ name = "port", takes_value = true, required = false },
|
||||
];
|
||||
token_create_flags : []FlagSpec = .[
|
||||
FlagSpec.{ name = "name", takes_value = true, required = true },
|
||||
FlagSpec.{ name = "local-store", takes_value = true, required = true },
|
||||
FlagSpec.{ name = "scope", takes_value = true, required = false },
|
||||
FlagSpec.{ name = "app", takes_value = true, required = false },
|
||||
FlagSpec.{ name = "channel", takes_value = true, required = false },
|
||||
FlagSpec.{ name = "expires-in", takes_value = true, required = false },
|
||||
];
|
||||
token_list_flags : []FlagSpec = .[
|
||||
FlagSpec.{ name = "local-store", takes_value = true, required = true },
|
||||
];
|
||||
token_revoke_flags : []FlagSpec = .[
|
||||
FlagSpec.{ name = "id", takes_value = true, required = true },
|
||||
FlagSpec.{ name = "local-store", takes_value = true, required = true },
|
||||
];
|
||||
cmds : []Command = .[
|
||||
Command.{ group = "ci", command = "publish", flags = publish_flags },
|
||||
Command.{ group = "release", command = "promote", flags = promote_flags },
|
||||
Command.{ group = "release", command = "rollback", flags = rollback_flags },
|
||||
Command.{ group = "server", command = "run", flags = server_flags },
|
||||
Command.{ group = "token", command = "create", flags = token_create_flags },
|
||||
Command.{ group = "token", command = "list", flags = token_list_flags },
|
||||
Command.{ group = "token", command = "revoke", flags = token_revoke_flags },
|
||||
];
|
||||
|
||||
diag : Diag = .{};
|
||||
|
||||
63
src/domain/token.sx
Normal file
63
src/domain/token.sx
Normal file
@@ -0,0 +1,63 @@
|
||||
#import "modules/std.sx";
|
||||
|
||||
// A scoped credential for CI and automation (subplan 02, Slice 5). The raw
|
||||
// secret is NEVER stored: `token_hash` is the lowercase-hex SHA-256 of the
|
||||
// secret, and the secret itself exists only in the `dist token create`
|
||||
// output. `scopes` is a space-separated list of scope words ("publish",
|
||||
// "read"). `app_slug` / `channel` narrow the token to one app and/or one
|
||||
// channel — empty means unrestricted, and the app scope is by SLUG so a
|
||||
// token can be minted before its app's first publish creates the app row.
|
||||
// Timestamps are unix epoch seconds; 0 means never (no expiry, never used,
|
||||
// not revoked).
|
||||
Token :: struct {
|
||||
id: string;
|
||||
name: string;
|
||||
token_hash: string;
|
||||
scopes: string;
|
||||
app_slug: string;
|
||||
channel: string;
|
||||
created_at: i64;
|
||||
expires_at: i64;
|
||||
last_used_at: i64;
|
||||
revoked_at: i64;
|
||||
}
|
||||
|
||||
// Why a token was refused. One tag per refusal class so the caller (CLI
|
||||
// now, the HTTP 401/403 paths in P4.4) can name the precise reason.
|
||||
TokenCheckErr :: error {
|
||||
Revoked,
|
||||
Expired,
|
||||
ScopeMissing,
|
||||
AppMismatch,
|
||||
ChannelMismatch,
|
||||
}
|
||||
|
||||
// True iff `word` appears as a whole space-separated word in `scopes`.
|
||||
has_scope :: (scopes: string, word: string) -> bool {
|
||||
i := 0;
|
||||
while i < scopes.len {
|
||||
// start of the next word
|
||||
while i < scopes.len and scopes[i] == 32 { i += 1; } // 32 = ' '
|
||||
start := i;
|
||||
while i < scopes.len and scopes[i] != 32 { i += 1; }
|
||||
if i > start {
|
||||
w := string.{ ptr = @scopes[start], len = i - start };
|
||||
if w == word { return true; }
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Decide whether `t` authorizes `scope` against (`app_slug`, `channel`) at
|
||||
// time `now`. Checks run in refusal-severity order: a revoked token reports
|
||||
// Revoked even if it is also expired. An empty `t.app_slug` / `t.channel`
|
||||
// matches anything; an empty REQUEST channel also passes the channel gate
|
||||
// (the operation has no channel to constrain, e.g. a read).
|
||||
check_token :: (t: Token, scope: string, app_slug: string, channel: string, now: i64) -> !TokenCheckErr {
|
||||
if t.revoked_at > 0 { raise error.Revoked; }
|
||||
if t.expires_at > 0 and now >= t.expires_at { raise error.Expired; }
|
||||
if !has_scope(t.scopes, scope) { raise error.ScopeMissing; }
|
||||
if t.app_slug.len > 0 and t.app_slug != app_slug { raise error.AppMismatch; }
|
||||
if t.channel.len > 0 and channel.len > 0 and t.channel != channel { raise error.ChannelMismatch; }
|
||||
return;
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
#import "release.sx";
|
||||
#import "artifact.sx";
|
||||
#import "channel.sx";
|
||||
#import "token.sx";
|
||||
|
||||
// Typed failures from boundary validation. One distinct tag per failure
|
||||
// class so callers (and, later, the CLI) can surface a precise message
|
||||
@@ -18,6 +19,8 @@ ValidationErr :: error {
|
||||
BadRollout, // rollout_percent outside 0..100
|
||||
BadSize, // artifact size_bytes was not positive
|
||||
BadDigest, // sha256 was not exactly 64 lowercase-hex chars
|
||||
BadTokenName, // token name empty or outside [a-z0-9._-]
|
||||
BadScope, // scopes empty or a word outside the known scope set
|
||||
}
|
||||
|
||||
// ── Character classes (ASCII byte codes) ────────────────────────────────
|
||||
@@ -171,3 +174,56 @@ validate_channel :: (c: Channel) -> !ValidationErr {
|
||||
if c.rollout_percent < 0 or c.rollout_percent > 100 { raise error.BadRollout; }
|
||||
return;
|
||||
}
|
||||
|
||||
// token name: non-empty; every byte in [a-z0-9._-] (e.g. "ci-main",
|
||||
// "release.bot").
|
||||
validate_token_name :: (s: string) -> !ValidationErr {
|
||||
if s.len == 0 { raise error.BadTokenName; }
|
||||
i := 0;
|
||||
while i < s.len {
|
||||
c := s[i];
|
||||
if !(is_lower(c) or is_digit(c) or c == 45 or c == 46 or c == 95) { // - . _
|
||||
raise error.BadTokenName;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// The closed scope vocabulary: `publish` writes releases, `read` only
|
||||
// inspects. New scopes are added here, never free-form.
|
||||
valid_scope_word :: (w: string) -> bool {
|
||||
return w == "publish" or w == "read";
|
||||
}
|
||||
|
||||
// scopes: at least one word; every space-separated word in the known set.
|
||||
validate_scopes :: (s: string) -> !ValidationErr {
|
||||
words := 0;
|
||||
i := 0;
|
||||
while i < s.len {
|
||||
while i < s.len and s[i] == 32 { i += 1; } // 32 = ' '
|
||||
start := i;
|
||||
while i < s.len and s[i] != 32 { i += 1; }
|
||||
if i > start {
|
||||
w := string.{ ptr = @s[start], len = i - start };
|
||||
if !valid_scope_word(w) { raise error.BadScope; }
|
||||
words += 1;
|
||||
}
|
||||
}
|
||||
if words == 0 { raise error.BadScope; }
|
||||
return;
|
||||
}
|
||||
|
||||
// Token requires id; name goes through validate_token_name, token_hash
|
||||
// through validate_sha256 (the hash IS a sha256 digest), scopes through
|
||||
// validate_scopes. The optional narrowing fields are validated only when
|
||||
// present: app_slug as a slug, channel as a channel name.
|
||||
validate_token :: (t: Token) -> !ValidationErr {
|
||||
if t.id.len == 0 { raise error.MissingField; }
|
||||
try validate_token_name(t.name);
|
||||
try validate_sha256(t.token_hash);
|
||||
try validate_scopes(t.scopes);
|
||||
if t.app_slug.len > 0 { try validate_slug(t.app_slug); }
|
||||
if t.channel.len > 0 { try validate_channel_name(t.channel); }
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
// thing that fixes db.json's layout is the ORDER these functions `put`.
|
||||
// Every entity is emitted in its struct's declaration-field order, and the
|
||||
// top-level object is emitted as apps, releases, artifacts, channels,
|
||||
// audit_events. Re-saving an unchanged model yields byte-identical output.
|
||||
// tokens, audit_events. Re-saving an unchanged model yields byte-identical
|
||||
// output.
|
||||
//
|
||||
// Enums serialize as their lowercase variant NAME (e.g. "android_apk",
|
||||
// "percentage"), never an ordinal — readable and reorder-proof.
|
||||
@@ -31,6 +32,7 @@ jsonp :: #import "modules/std/json.sx";
|
||||
#import "../domain/release.sx";
|
||||
#import "../domain/artifact.sx";
|
||||
#import "../domain/channel.sx";
|
||||
#import "../domain/token.sx";
|
||||
#import "../domain/audit.sx";
|
||||
#import "../domain/validate.sx";
|
||||
#import "repo.sx";
|
||||
@@ -156,6 +158,21 @@ channel_to_json :: (c: Channel, alloc: Allocator) -> Value {
|
||||
return .object(o);
|
||||
}
|
||||
|
||||
token_to_json :: (t: Token, alloc: Allocator) -> Value {
|
||||
o : Object = .{};
|
||||
o.put("id", .str(t.id), alloc);
|
||||
o.put("name", .str(t.name), alloc);
|
||||
o.put("token_hash", .str(t.token_hash), alloc);
|
||||
o.put("scopes", .str(t.scopes), alloc);
|
||||
o.put("app_slug", .str(t.app_slug), alloc);
|
||||
o.put("channel", .str(t.channel), alloc);
|
||||
o.put("created_at", .int_(t.created_at), alloc);
|
||||
o.put("expires_at", .int_(t.expires_at), alloc);
|
||||
o.put("last_used_at", .int_(t.last_used_at), alloc);
|
||||
o.put("revoked_at", .int_(t.revoked_at), alloc);
|
||||
return .object(o);
|
||||
}
|
||||
|
||||
audit_to_json :: (e: AuditEvent, alloc: Allocator) -> Value {
|
||||
o : Object = .{};
|
||||
o.put("id", .str(e.id), alloc);
|
||||
@@ -193,6 +210,11 @@ model_to_json :: (self: *Repo, alloc: Allocator) -> Value {
|
||||
while i < self.channels.len { chans.add(channel_to_json(self.channels.items[i], alloc), alloc); i += 1; }
|
||||
root.put("channels", .array(chans), alloc);
|
||||
|
||||
toks : Array = .{};
|
||||
i = 0;
|
||||
while i < self.tokens.len { toks.add(token_to_json(self.tokens.items[i], alloc), alloc); i += 1; }
|
||||
root.put("tokens", .array(toks), alloc);
|
||||
|
||||
evs : Array = .{};
|
||||
i = 0;
|
||||
while i < self.audit_events.len { evs.add(audit_to_json(self.audit_events.items[i], alloc), alloc); i += 1; }
|
||||
@@ -354,6 +376,21 @@ channel_from_json :: (o: Object, alloc: Allocator) -> (Channel, !LoadErr) {
|
||||
return c;
|
||||
}
|
||||
|
||||
token_from_json :: (o: Object, alloc: Allocator) -> (Token, !LoadErr) {
|
||||
t : Token = .{};
|
||||
t.id = try req_str(o, "id", alloc);
|
||||
t.name = try req_str(o, "name", alloc);
|
||||
t.token_hash = try req_str(o, "token_hash", alloc);
|
||||
t.scopes = try req_str(o, "scopes", alloc);
|
||||
t.app_slug = try req_str(o, "app_slug", alloc);
|
||||
t.channel = try req_str(o, "channel", alloc);
|
||||
t.created_at = try req_int(o, "created_at");
|
||||
t.expires_at = try req_int(o, "expires_at");
|
||||
t.last_used_at = try req_int(o, "last_used_at");
|
||||
t.revoked_at = try req_int(o, "revoked_at");
|
||||
return t;
|
||||
}
|
||||
|
||||
audit_from_json :: (o: Object, alloc: Allocator) -> (AuditEvent, !LoadErr) {
|
||||
e : AuditEvent = .{};
|
||||
e.id = try req_str(o, "id", alloc);
|
||||
@@ -407,6 +444,22 @@ load_into :: (repo: *Repo, bytes: string, scratch: Allocator) -> !LoadErr {
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// OPTIONAL member: an absent `tokens` array loads as zero tokens, so
|
||||
// db.json files from layouts without tokens stay readable. A PRESENT
|
||||
// member is held to the same strictness as everything else.
|
||||
tokq := db_obj_find(ro, "tokens");
|
||||
if tokq != null {
|
||||
tokv := tokq!;
|
||||
if tokv != .array { raise error.BadShape; }
|
||||
tok_arr := tokv.array;
|
||||
i = 0;
|
||||
while i < tok_arr.len {
|
||||
o := try db_req_obj(tok_arr.items[i]);
|
||||
repo.create_token(try token_from_json(o, oa));
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
ev_arr := try db_req_arr(ro, "audit_events");
|
||||
i = 0;
|
||||
while i < ev_arr.len {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
#import "../domain/release.sx";
|
||||
#import "../domain/artifact.sx";
|
||||
#import "../domain/channel.sx";
|
||||
#import "../domain/token.sx";
|
||||
#import "../domain/audit.sx";
|
||||
#import "../domain/validate.sx";
|
||||
|
||||
@@ -44,6 +45,7 @@ Repo :: struct {
|
||||
releases: List(Release);
|
||||
artifacts: List(Artifact);
|
||||
channels: List(Channel);
|
||||
tokens: List(Token);
|
||||
audit_events: List(AuditEvent);
|
||||
|
||||
// Capture the owning allocator. The List fields default to empty
|
||||
@@ -170,6 +172,39 @@ Repo :: struct {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Tokens ───────────────────────────────────────────────────────
|
||||
create_token :: (self: *Repo, t: Token) {
|
||||
self.tokens.append(t, self.own_allocator);
|
||||
}
|
||||
get_token :: (self: *Repo, id: string) -> ?Token {
|
||||
i := 0;
|
||||
while i < self.tokens.len {
|
||||
if self.tokens.items[i].id == id { return self.tokens.items[i]; }
|
||||
i += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// The auth lookup: a presented secret is hashed and matched here.
|
||||
find_token_by_hash :: (self: *Repo, token_hash: string) -> ?Token {
|
||||
i := 0;
|
||||
while i < self.tokens.len {
|
||||
if self.tokens.items[i].token_hash == token_hash { return self.tokens.items[i]; }
|
||||
i += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
list_tokens :: (self: *Repo) -> []Token {
|
||||
return .{ ptr = self.tokens.items, len = self.tokens.len };
|
||||
}
|
||||
update_token :: (self: *Repo, t: Token) -> bool {
|
||||
i := 0;
|
||||
while i < self.tokens.len {
|
||||
if self.tokens.items[i].id == t.id { self.tokens.items[i] = t; return true; }
|
||||
i += 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Audit events ─────────────────────────────────────────────────
|
||||
create_audit_event :: (self: *Repo, e: AuditEvent) {
|
||||
self.audit_events.append(e, self.own_allocator);
|
||||
|
||||
359
src/token/ops.sx
Normal file
359
src/token/ops.sx
Normal file
@@ -0,0 +1,359 @@
|
||||
// =====================================================================
|
||||
// ops.sx (token) — token security at rest over the persisted store
|
||||
// (subplan 02, Slice 5 / P4.3): `dist token create`, `dist token list`,
|
||||
// `dist token revoke`.
|
||||
//
|
||||
// SECRET LIFECYCLE: `create` draws 32 bytes from `arc4random_buf` (libc —
|
||||
// the same thin-platform-backend boundary as publish's `time(2)`), renders
|
||||
// them as `dist_<64 lowercase hex>`, and returns that secret to the caller
|
||||
// EXACTLY ONCE. Only the lowercase-hex SHA-256 of the secret is stored
|
||||
// (`Token.token_hash`); nothing under the store can reproduce the secret.
|
||||
// Verification (the P4.4 server, or anything holding a presented secret)
|
||||
// re-hashes and looks up `find_token_by_hash`, then gates through the
|
||||
// domain's `check_token` (revocation, expiry, scope, app/channel match)
|
||||
// and stamps usage via `mark_token_used`.
|
||||
//
|
||||
// STORE STATE: `create` and `list` treat an absent db.json as an empty
|
||||
// store — CI tokens are minted BEFORE the first publish creates any state.
|
||||
// A present-but-unreadable db.json is still a loud Load failure.
|
||||
//
|
||||
// FAILURE CONTRACT (as everywhere): every abort happens before `db.save`,
|
||||
// so a failed operation never changes db.json. Each raise site first
|
||||
// writes a `jout.CliFailure` (stable dotted code + human message).
|
||||
// =====================================================================
|
||||
|
||||
#import "modules/std.sx";
|
||||
#import "modules/std/json.sx";
|
||||
#import "modules/std/fs.sx";
|
||||
#import "../domain/platform.sx";
|
||||
#import "../domain/app.sx";
|
||||
#import "../domain/release.sx";
|
||||
#import "../domain/artifact.sx";
|
||||
#import "../domain/channel.sx";
|
||||
#import "../domain/token.sx";
|
||||
#import "../domain/audit.sx";
|
||||
#import "../domain/validate.sx";
|
||||
#import "../repo/repo.sx";
|
||||
#import "../store/store.sx";
|
||||
db :: #import "../repo/db.sx";
|
||||
jout :: #import "../json_out.sx";
|
||||
pl :: #import "../publish/publish.sx";
|
||||
|
||||
tokc :: #library "c";
|
||||
arc4random_buf :: (buf: [*]u8, n: usize) #foreign tokc "arc4random_buf";
|
||||
|
||||
// Failure classes for a token operation; the precise reason travels in the
|
||||
// caller's `jout.CliFailure`.
|
||||
// Load — db.json exists but could not be loaded.
|
||||
// NotFound — no token with the given id.
|
||||
// Invalid — the token fails domain validation, or the operation is
|
||||
// meaningless (revoking an already-revoked token).
|
||||
// Persist — db.json could not be re-written.
|
||||
TokOpError :: error {
|
||||
Load,
|
||||
NotFound,
|
||||
Invalid,
|
||||
Persist,
|
||||
}
|
||||
|
||||
TokenCreateOutcome :: struct {
|
||||
id: string;
|
||||
name: string;
|
||||
secret: string; // the only copy that will ever exist
|
||||
scopes: string;
|
||||
app_slug: string;
|
||||
channel: string;
|
||||
created_at: i64;
|
||||
expires_at: i64;
|
||||
}
|
||||
|
||||
TokenListOutcome :: struct {
|
||||
tokens: []Token;
|
||||
now: i64;
|
||||
}
|
||||
|
||||
TokenRevokeOutcome :: struct {
|
||||
id: string;
|
||||
name: string;
|
||||
revoked_at: i64;
|
||||
}
|
||||
|
||||
// ── shared steps ──────────────────────────────────────────────────────
|
||||
|
||||
// Load the persisted model when one exists; start empty otherwise (a fresh
|
||||
// store is a valid place to mint the first token). Only a PRESENT db.json
|
||||
// that fails to load is an error.
|
||||
tok_load_or_empty :: (store_dir: string, fail_out: *jout.CliFailure) -> (Repo, !TokOpError) {
|
||||
if !exists(path_join(store_dir, "db.json")) {
|
||||
return Repo.init();
|
||||
}
|
||||
loaded, le := db.load(store_dir);
|
||||
if le {
|
||||
fail_out.code = "store.load";
|
||||
fail_out.message = concat("db.json under the store could not be loaded: ", store_dir);
|
||||
raise error.Load;
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
|
||||
tok_save :: (repo: *Repo, store_dir: string, fail_out: *jout.CliFailure) -> !TokOpError {
|
||||
werr := false;
|
||||
db.save(repo, store_dir) catch { werr = true; };
|
||||
if werr {
|
||||
fail_out.code = "persist.save";
|
||||
fail_out.message = concat("db.json could not be written under the store: ", store_dir);
|
||||
raise error.Persist;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 32 entropy bytes as `dist_<64 lowercase hex>`, heap-allocated from the
|
||||
// context allocator. The `dist_` prefix makes a leaked secret recognizable
|
||||
// in logs and scanners.
|
||||
generate_secret :: () -> string {
|
||||
HEX :: "0123456789abcdef";
|
||||
raw : [32]u8 = ---;
|
||||
arc4random_buf(@raw[0], 32);
|
||||
|
||||
n := 5 + 64;
|
||||
dst : [*]u8 = xx context.allocator.alloc_bytes(n + 1);
|
||||
dst[0] = 100; dst[1] = 105; dst[2] = 115; dst[3] = 116; dst[4] = 95; // "dist_"
|
||||
i := 0;
|
||||
while i < 32 {
|
||||
b := raw[i];
|
||||
dst[5 + i * 2] = HEX[b >> 4];
|
||||
dst[5 + i * 2 + 1] = HEX[b & 15];
|
||||
i += 1;
|
||||
}
|
||||
dst[n] = 0;
|
||||
return string.{ ptr = dst, len = n };
|
||||
}
|
||||
|
||||
// Lifecycle status of a token at time `now`, for list output. Revocation
|
||||
// outranks expiry, matching check_token's refusal order.
|
||||
token_status :: (t: Token, now: i64) -> string {
|
||||
if t.revoked_at > 0 { return "revoked"; }
|
||||
if t.expires_at > 0 and now >= t.expires_at { return "expired"; }
|
||||
return "active";
|
||||
}
|
||||
|
||||
// Stamp `id`'s last use. False when the id is unknown (the caller already
|
||||
// authenticated, so that means the token vanished mid-request).
|
||||
mark_token_used :: (repo: *Repo, id: string, now: i64) -> bool {
|
||||
tq := repo.get_token(id);
|
||||
if tq == null { return false; }
|
||||
t := tq!;
|
||||
t.last_used_at = now;
|
||||
return repo.update_token(t);
|
||||
}
|
||||
|
||||
// ── create ────────────────────────────────────────────────────────────
|
||||
|
||||
token_invalid_code :: (e: ValidationErr) -> string {
|
||||
if e == error.BadTokenName { return "token.bad_name"; }
|
||||
if e == error.BadScope { return "token.bad_scope"; }
|
||||
if e == error.BadSlug { return "token.bad_app_slug"; }
|
||||
if e == error.BadChannelName { return "token.bad_channel"; }
|
||||
return "token.invalid";
|
||||
}
|
||||
|
||||
token_invalid_message :: (e: ValidationErr) -> string {
|
||||
if e == error.BadTokenName { return "token name must be non-empty, charset [a-z0-9._-]"; }
|
||||
if e == error.BadScope { return "scopes must be space-separated words from: publish read"; }
|
||||
if e == error.BadSlug { return "app scope must be a valid slug ([a-z0-9-], no edge/double hyphens)"; }
|
||||
if e == error.BadChannelName { return "channel scope must be a valid channel name ([a-z0-9-])"; }
|
||||
return "token fails domain validation";
|
||||
}
|
||||
|
||||
run_token_create :: (store_dir: string, name: string, scopes: string, app_slug: string, channel: string, expires_in: i64, fail_out: *jout.CliFailure) -> (TokenCreateOutcome, !TokOpError) {
|
||||
repo := try tok_load_or_empty(store_dir, fail_out);
|
||||
|
||||
eff_scopes := if scopes.len > 0 then scopes else "publish";
|
||||
now := pl.now_secs();
|
||||
|
||||
secret := generate_secret();
|
||||
token_hash := digest_of_bytes(secret);
|
||||
id := concat("tok-", substr(token_hash, 0, 12));
|
||||
|
||||
t := Token.{
|
||||
id = id, name = name, token_hash = token_hash,
|
||||
scopes = eff_scopes, app_slug = app_slug, channel = channel,
|
||||
created_at = now,
|
||||
expires_at = if expires_in > 0 then now + expires_in else 0,
|
||||
last_used_at = 0, revoked_at = 0,
|
||||
};
|
||||
|
||||
verr := false;
|
||||
vcode := "";
|
||||
vmsg := "";
|
||||
validate_token(t) catch (e) {
|
||||
verr = true;
|
||||
vcode = token_invalid_code(e);
|
||||
vmsg = token_invalid_message(e);
|
||||
};
|
||||
if verr {
|
||||
fail_out.code = vcode;
|
||||
fail_out.message = vmsg;
|
||||
raise error.Invalid;
|
||||
}
|
||||
if repo.get_token(id) != null {
|
||||
fail_out.code = "token.id_collision";
|
||||
fail_out.message = concat("a token with this id already exists (re-run create): ", id);
|
||||
raise error.Invalid;
|
||||
}
|
||||
|
||||
repo.create_token(t);
|
||||
repo.create_audit_event(AuditEvent.{
|
||||
id = concat("evt-token-create-", id), actor = "cli",
|
||||
action = "token.create", target_type = "token",
|
||||
target_id = id, metadata = name, created_at = now,
|
||||
});
|
||||
try tok_save(@repo, store_dir, fail_out);
|
||||
|
||||
return TokenCreateOutcome.{
|
||||
id = id, name = name, secret = secret,
|
||||
scopes = eff_scopes, app_slug = app_slug, channel = channel,
|
||||
created_at = now, expires_at = t.expires_at,
|
||||
};
|
||||
}
|
||||
|
||||
// ── list ──────────────────────────────────────────────────────────────
|
||||
|
||||
run_token_list :: (store_dir: string, fail_out: *jout.CliFailure) -> (TokenListOutcome, !TokOpError) {
|
||||
repo := try tok_load_or_empty(store_dir, fail_out);
|
||||
return TokenListOutcome.{ tokens = repo.list_tokens(), now = pl.now_secs() };
|
||||
}
|
||||
|
||||
// ── revoke ────────────────────────────────────────────────────────────
|
||||
|
||||
run_token_revoke :: (store_dir: string, id: string, fail_out: *jout.CliFailure) -> (TokenRevokeOutcome, !TokOpError) {
|
||||
repo := try tok_load_or_empty(store_dir, fail_out);
|
||||
|
||||
tq := repo.get_token(id);
|
||||
if tq == null {
|
||||
fail_out.code = "token.unknown";
|
||||
fail_out.message = concat("no token with that id in the store: ", id);
|
||||
raise error.NotFound;
|
||||
}
|
||||
t := tq!;
|
||||
if t.revoked_at > 0 {
|
||||
fail_out.code = "token.already_revoked";
|
||||
fail_out.message = concat("token is already revoked: ", id);
|
||||
raise error.Invalid;
|
||||
}
|
||||
|
||||
now := pl.now_secs();
|
||||
t.revoked_at = now;
|
||||
repo.update_token(t);
|
||||
repo.create_audit_event(AuditEvent.{
|
||||
id = concat("evt-token-revoke-", id), actor = "cli",
|
||||
action = "token.revoke", target_type = "token",
|
||||
target_id = id, metadata = t.name, created_at = now,
|
||||
});
|
||||
try tok_save(@repo, store_dir, fail_out);
|
||||
|
||||
return TokenRevokeOutcome.{ id = id, name = t.name, revoked_at = now };
|
||||
}
|
||||
|
||||
// ── rendering ─────────────────────────────────────────────────────────
|
||||
|
||||
// `{"status":"created","token":{id,name,secret,scopes,app_slug,channel,
|
||||
// created_at,expires_at}}` — the ONE machine-readable copy of the secret.
|
||||
write_token_create_json :: (o: *TokenCreateOutcome, dst: []u8) -> (i64, !JsonError) {
|
||||
gpa := GPA.init();
|
||||
root : Object = .{};
|
||||
root.put("status", .str("created"), xx gpa);
|
||||
to : Object = .{};
|
||||
to.put("id", .str(o.id), xx gpa);
|
||||
to.put("name", .str(o.name), xx gpa);
|
||||
to.put("secret", .str(o.secret), xx gpa);
|
||||
to.put("scopes", .str(o.scopes), xx gpa);
|
||||
to.put("app_slug", .str(o.app_slug), xx gpa);
|
||||
to.put("channel", .str(o.channel), xx gpa);
|
||||
to.put("created_at", .int_(o.created_at), xx gpa);
|
||||
to.put("expires_at", .int_(o.expires_at), xx gpa);
|
||||
root.put("token", .object(to), xx gpa);
|
||||
rootv : Value = .object(root);
|
||||
n := try write_to_buffer(rootv, dst);
|
||||
return n;
|
||||
}
|
||||
|
||||
// `{"status":"ok","tokens":[{id,name,scopes,app_slug,channel,created_at,
|
||||
// expires_at,last_used_at,revoked_at,status}]}` — hashes and secrets never
|
||||
// appear in list output.
|
||||
write_token_list_json :: (o: *TokenListOutcome, dst: []u8) -> (i64, !JsonError) {
|
||||
gpa := GPA.init();
|
||||
root : Object = .{};
|
||||
root.put("status", .str("ok"), xx gpa);
|
||||
arr : Array = .{};
|
||||
i := 0;
|
||||
while i < o.tokens.len {
|
||||
t := o.tokens[i];
|
||||
to : Object = .{};
|
||||
to.put("id", .str(t.id), xx gpa);
|
||||
to.put("name", .str(t.name), xx gpa);
|
||||
to.put("scopes", .str(t.scopes), xx gpa);
|
||||
to.put("app_slug", .str(t.app_slug), xx gpa);
|
||||
to.put("channel", .str(t.channel), xx gpa);
|
||||
to.put("created_at", .int_(t.created_at), xx gpa);
|
||||
to.put("expires_at", .int_(t.expires_at), xx gpa);
|
||||
to.put("last_used_at", .int_(t.last_used_at), xx gpa);
|
||||
to.put("revoked_at", .int_(t.revoked_at), xx gpa);
|
||||
to.put("status", .str(token_status(t, o.now)), xx gpa);
|
||||
arr.add(.object(to), xx gpa);
|
||||
i += 1;
|
||||
}
|
||||
root.put("tokens", .array(arr), xx gpa);
|
||||
rootv : Value = .object(root);
|
||||
n := try write_to_buffer(rootv, dst);
|
||||
return n;
|
||||
}
|
||||
|
||||
// `{"status":"revoked","token":{id,name,revoked_at}}`.
|
||||
write_token_revoke_json :: (o: *TokenRevokeOutcome, dst: []u8) -> (i64, !JsonError) {
|
||||
gpa := GPA.init();
|
||||
root : Object = .{};
|
||||
root.put("status", .str("revoked"), xx gpa);
|
||||
to : Object = .{};
|
||||
to.put("id", .str(o.id), xx gpa);
|
||||
to.put("name", .str(o.name), xx gpa);
|
||||
to.put("revoked_at", .int_(o.revoked_at), xx gpa);
|
||||
root.put("token", .object(to), xx gpa);
|
||||
rootv : Value = .object(root);
|
||||
n := try write_to_buffer(rootv, dst);
|
||||
return n;
|
||||
}
|
||||
|
||||
token_create_human :: (o: *TokenCreateOutcome) -> string {
|
||||
s := concat("created token ", concat(o.id, concat(" (", concat(o.name, ")\n"))));
|
||||
s = concat(s, concat(" secret: ", concat(o.secret, " (shown once — store it now)\n")));
|
||||
s = concat(s, concat(" scopes: ", o.scopes));
|
||||
s = concat(s, concat(" app: ", if o.app_slug.len > 0 then o.app_slug else "(any)"));
|
||||
s = concat(s, concat(" channel: ", if o.channel.len > 0 then o.channel else "(any)"));
|
||||
if o.expires_at > 0 {
|
||||
s = concat(s, concat(" expires_at: ", int_to_string(o.expires_at)));
|
||||
} else {
|
||||
s = concat(s, " expires: never");
|
||||
}
|
||||
return concat(s, "\n");
|
||||
}
|
||||
|
||||
token_list_human :: (o: *TokenListOutcome) -> string {
|
||||
if o.tokens.len == 0 { return "no tokens in the store\n"; }
|
||||
s := "";
|
||||
i := 0;
|
||||
while i < o.tokens.len {
|
||||
t := o.tokens[i];
|
||||
s = concat(s, concat(t.id, concat(" ", concat(t.name, concat(" ", token_status(t, o.now))))));
|
||||
s = concat(s, concat(" scopes: ", t.scopes));
|
||||
s = concat(s, concat(" app: ", if t.app_slug.len > 0 then t.app_slug else "(any)"));
|
||||
s = concat(s, concat(" channel: ", if t.channel.len > 0 then t.channel else "(any)"));
|
||||
s = concat(s, "\n");
|
||||
i += 1;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
token_revoke_human :: (o: *TokenRevokeOutcome) -> string {
|
||||
return concat("revoked token ", concat(o.id, concat(" (", concat(o.name, ")\n"))));
|
||||
}
|
||||
Reference in New Issue
Block a user