distd stops being read-only. http.sx learns the write-side request surface: header capture with case-insensitive lookup, a Content-Length- bounded body read loop (8K header cap, 512 MiB body cap -> 413, 411 for length-less POST/PUT), and the matching status texts. Auth (server/auth.sx): Authorization: Bearer is re-hashed and resolved via find_token_by_hash, then gated through check_token — 401 for missing/malformed/unknown credentials, 403 with a refusal-specific code (auth.revoked/expired/missing_scope/app_forbidden/channel_forbidden); successful auth stamps last_used_at. check_token's app gate now treats an empty REQUEST app like an empty request channel (uploads are app-agnostic until a release references them). Write routes (POST, publish scope): /api/upload content-addresses the body; /api/apps/<slug>/releases publishes over already-uploaded objects through commit_publish — the back half extracted from run_publish so CLI and HTTP publishes share one find/create-app -> transaction -> audit -> persist pipeline; channels/<name>/promote|rollback delegate to the P3.5 CLI pipelines. Reads stay public. make test 17/17 (new: server_write.sx pinned acceptance over curl).
137 lines
5.1 KiB
Plaintext
137 lines
5.1 KiB
Plaintext
// =====================================================================
|
|
// auth.sx — bearer-token authentication for distd's write surface
|
|
// (subplan 04, Slice 2).
|
|
//
|
|
// `authenticate` takes the raw header block plus the (scope, app, channel)
|
|
// the operation demands, and either returns the live Token or refuses:
|
|
//
|
|
// 401 Unauthorized — no `Authorization` header, a non-Bearer scheme, or
|
|
// a secret whose hash matches no stored token. The response never
|
|
// distinguishes "no such token" from "wrong secret": both are
|
|
// `auth.unknown_token`.
|
|
// 403 Forbidden — the token exists but `check_token` refuses it; the
|
|
// code names the refusal (auth.revoked / auth.expired /
|
|
// auth.missing_scope / auth.app_forbidden / auth.channel_forbidden).
|
|
// 503 Unavailable — db.json exists but could not be loaded.
|
|
//
|
|
// The presented secret is re-hashed (`digest_of_bytes`, the store's
|
|
// SHA-256) and matched against hashes at rest — the secret itself is never
|
|
// stored or logged. Successful auth stamps `last_used_at` and persists;
|
|
// the stamp is best-effort (a failed save must not fail an authorized
|
|
// request — the operation's own save is the authoritative write).
|
|
// =====================================================================
|
|
|
|
#import "modules/std.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 "../repo/repo.sx";
|
|
#import "../store/store.sx";
|
|
db :: #import "../repo/db.sx";
|
|
jout :: #import "../json_out.sx";
|
|
pl :: #import "../publish/publish.sx";
|
|
tops :: #import "../token/ops.sx";
|
|
http :: #import "http.sx";
|
|
|
|
AuthErr :: error {
|
|
Unauthorized, // -> 401
|
|
Forbidden, // -> 403
|
|
Unavailable, // -> 503
|
|
}
|
|
|
|
// The secret inside `Authorization: Bearer <secret>`, or null when the
|
|
// value does not carry the Bearer scheme (case-insensitive, per RFC 7235)
|
|
// or carries an empty credential.
|
|
bearer_secret :: (header: string) -> ?string {
|
|
SCHEME :: "bearer ";
|
|
if header.len <= 7 { return null; }
|
|
i := 0;
|
|
while i < 7 {
|
|
c := header[i];
|
|
if c >= 65 and c <= 90 { c += 32; } // ASCII lower
|
|
if c != SCHEME[i] { return null; }
|
|
i += 1;
|
|
}
|
|
return string.{ ptr = @header[7], len = header.len - 7 };
|
|
}
|
|
|
|
check_code :: (e: TokenCheckErr) -> string {
|
|
if e == error.Revoked { return "auth.revoked"; }
|
|
if e == error.Expired { return "auth.expired"; }
|
|
if e == error.ScopeMissing { return "auth.missing_scope"; }
|
|
if e == error.AppMismatch { return "auth.app_forbidden"; }
|
|
return "auth.channel_forbidden";
|
|
}
|
|
|
|
check_message :: (e: TokenCheckErr, scope: string, app_slug: string, channel: string) -> string {
|
|
if e == error.Revoked { return "token has been revoked"; }
|
|
if e == error.Expired { return "token has expired"; }
|
|
if e == error.ScopeMissing { return concat("token lacks the required scope: ", scope); }
|
|
if e == error.AppMismatch { return concat("token is not scoped to this app: ", app_slug); }
|
|
return concat("token is not scoped to this channel: ", channel);
|
|
}
|
|
|
|
// Authenticate the request these headers came with, demanding `scope`
|
|
// against (`app_slug`, `channel`) — "" where the operation has no app or
|
|
// channel to constrain.
|
|
authenticate :: (store_dir: string, headers: string, scope: string, app_slug: string, channel: string, fail_out: *jout.CliFailure) -> (Token, !AuthErr) {
|
|
hq := http.header_value(headers, "authorization");
|
|
if hq == null {
|
|
fail_out.code = "auth.missing";
|
|
fail_out.message = "this route requires Authorization: Bearer <token>";
|
|
raise error.Unauthorized;
|
|
}
|
|
sq := bearer_secret(hq!);
|
|
if sq == null {
|
|
fail_out.code = "auth.malformed";
|
|
fail_out.message = "Authorization header is not a Bearer credential";
|
|
raise error.Unauthorized;
|
|
}
|
|
presented_hash := digest_of_bytes(sq!);
|
|
|
|
if !exists(path_join(store_dir, "db.json")) {
|
|
fail_out.code = "auth.unknown_token";
|
|
fail_out.message = "unknown token";
|
|
raise error.Unauthorized;
|
|
}
|
|
repo, 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.Unavailable;
|
|
}
|
|
|
|
tq := repo.find_token_by_hash(presented_hash);
|
|
if tq == null {
|
|
fail_out.code = "auth.unknown_token";
|
|
fail_out.message = "unknown token";
|
|
raise error.Unauthorized;
|
|
}
|
|
t := tq!;
|
|
|
|
now := pl.now_secs();
|
|
cerr := false;
|
|
ccode := "";
|
|
cmsg := "";
|
|
check_token(t, scope, app_slug, channel, now) catch (e) {
|
|
cerr = true;
|
|
ccode = check_code(e);
|
|
cmsg = check_message(e, scope, app_slug, channel);
|
|
};
|
|
if cerr {
|
|
fail_out.code = ccode;
|
|
fail_out.message = cmsg;
|
|
raise error.Forbidden;
|
|
}
|
|
|
|
tops.mark_token_used(@repo, t.id, now);
|
|
db.save(@repo, store_dir) catch {}; // best-effort stamp (see header)
|
|
|
|
return t;
|
|
}
|