diff --git a/README.md b/README.md index 11b8917..9825a55 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # @lazurio/module-kit Runtime helpers for Apps that follow the **Lazurio Module Standard**: read the -listener a Lazurio Platform assigned, answer health, and shut down cleanly on -`SIGTERM`. +listener a Lazurio Platform assigned, answer health, shut down cleanly on +`SIGTERM`, and learn who came to the App and with which application role. This is the **only shared runtime library of a Module**. It stays small on purpose: reading the runtime environment, health, shutdown and dev server @@ -12,7 +12,7 @@ beyond that belongs in the Module itself. ## Install ```sh -bun add github:Lazurio/module-kit#v0.2.0 +bun add github:Lazurio/module-kit#v0.3.0 ``` The package ships TypeScript sources (`exports` and `types` point at @@ -61,9 +61,82 @@ try { | `onShutdown(fn, { timeoutMs = 10_000 })` | on `SIGTERM`/`SIGINT`, runs `fn` once and exits 0; exits 1 if `fn` throws or does not finish within `timeoutMs`; returns a function that removes the handlers | | `requireEnvironment(names, env)` | the values as a record; throws `environment-missing` naming every absent or empty variable | +| `appAccess(request, id = "app", env)` | who came to the App for this request and with which application role: always `{ entry, appRole, person, source, reason }`, see [Who came and with which role](#who-came-and-with-which-role) | +| `isLazurioUsername(value)` | whether a value is a Lazurio username (3–39 lowercase letters and digits, single dashes between them, not UUID-shaped, not a service account) | + `ModuleKitError.code` is one of `listener-missing`, `listeners-invalid` or `environment-missing`. +## Who came and with which role + +Lazurio decides who may open a Module's App and with which application role, +application admin or application user, and the Organization decides in the +Module's code what each role may do (Lazurio decision 0231). `appAccess` is the +only way a Module learns it, and its answer is the stable contract: its fields +and values never change meaning or disappear, and new information comes as new +fields. The contract is the Module Standard's "Who came and with which role" +in Lazurio Platform (`docs/module-standard.md`). + +```ts +import { appAccess } from "@lazurio/module-kit"; + +// inside Bun.serve fetch(request), on every request, including APIs +const access = appAccess(request, "app"); +if (access.appRole === null) + return new Response("Lazurio did not say who you are.", { status: 403 }); +const mayApprove = access.appRole === "admin"; // the Organization's own rule +const author = access.person?.subject ?? null; // null: from inside the Environment +``` + +Every answer has the same five fields: + +| `entry` | `appRole` | `person` | `source` | `reason` | When | +| --- | --- | --- | --- | --- | --- | +| `environment` | `admin` | subject and username | `gateway` | `null` | the person has the whole Environment where the App runs | +| `app` | `admin` or `user` | subject and username | `gateway` | `null` | the person has this App only, with the role the Organization gave them | +| `local` | `admin` | `null` | `null` | `not-hosted`, `not-through-gateway`, `local-entry` | from inside the Environment: no gateway in front of the App (a person's own computer), a request to a loopback address (a sibling, a script, an agent), or the Environment browser's local entry | +| `unknown` | `null` | `null` | `null` | `no-identity` | through the gateway without a valid statement, or a `Host` that is neither the App's hostname nor loopback: no role, show "unavailable" | + +`source` says who stated the person: `gateway` today, from its own admission +answer after removing every copy a client sent; an identity signed by Lazurio +Auth for use outside the Environment will come as `auth`. A request whose +`Host` names the App's external hostname, in any letter case, with any port or +a trailing dot, as the gateway matches it, counts only by the gateway's +statement; a missing, repeated or malformed statement is `unknown`, never a +role. + +- **Enforce on the server, per request.** Check the role for every request, + including APIs, downloads, WebSockets and direct URLs, never only by hiding a + button, and never cache it: after another account signs in, the next request + names the new person. +- **Remember a person by `subject`**, show `username` (`null` when the gateway + named none). Never fill in a name from the browser, the operating system + account or the Environment's assignee. +- **Finer roles are the Module's.** Approvers, technicians and the like live in + the Module's own data, keyed by `subject`. Lazurio keeps only the two roles. +- **Its boundary is the Environment.** A process on the Environment that reaches + the App's loopback port could send anything, as it could read the App's data. + The statement holds against visitors who have only the App, not against the + Environment's operator. Do not forward it to a sibling or another service. +- **Transport is not the contract.** `GATEWAY_HEADERS` names the headers the + gateway uses today so that a Module's own tests can build requests; read them + only through `appAccess`. + +**Proof through the real gateway.** `bun proof/gateway.ts ` +renders the Lazurio workspace gateway from a Machines checkout, runs it with +Caddy in front of a TypeScript App and a Python App and checks every Host form +a client can send: the App's hostname as is, in upper case, with `:443` or +another port (the gateway's statement counts: `environment`/admin with a +person, `unknown` without one, nothing without a session) and with a trailing +dot (refused by the gateway itself). A client's forged statement never counts +and no request through the gateway is `local`. It needs Caddy, OpenSSL and uv, +so it is not part of `bun test`. + +Python: `app_access(headers, "app")` takes the request headers as a mapping +(matched case-insensitively; a repeated header reads as none) and returns +`AppAccess(entry, app_role, person, source, reason)` with +`AppPerson(subject, username)` or `None`, by the same rules. + ## Bun.serve ```ts @@ -119,11 +192,11 @@ export default defineConfig({ ## Python -The `python/` directory holds `lazurio-module-kit`, the same `listener` and -`health` semantics for a Python Module (`uv` + `pyproject.toml`): +The `python/` directory holds `lazurio-module-kit`, the same `listener`, +`health` and `app_access` semantics for a Python Module (`uv` + `pyproject.toml`): ```sh -uv add "lazurio-module-kit @ git+https://github.com/Lazurio/module-kit@v0.1.0#subdirectory=python" +uv add "lazurio-module-kit @ git+https://github.com/Lazurio/module-kit@v0.3.0#subdirectory=python" ``` ```python diff --git a/biome.json b/biome.json index b2f36ea..f057955 100644 --- a/biome.json +++ b/biome.json @@ -4,6 +4,7 @@ "includes": [ "src/**/*.ts", "test/**/*.ts", + "proof/**/*.ts", "package.json", "tsconfig.json", "biome.json" diff --git a/package.json b/package.json index b786e78..c88b332 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@lazurio/module-kit", - "version": "0.2.0", - "description": "Lazurio Module Standard runtime helpers: listener, health, shutdown", + "version": "0.3.0", + "description": "Lazurio Module Standard runtime helpers: listener, health, shutdown, app access", "license": "MIT", "type": "module", "packageManager": "bun@1.4.2", diff --git a/proof/gateway.ts b/proof/gateway.ts new file mode 100644 index 0000000..fd41394 --- /dev/null +++ b/proof/gateway.ts @@ -0,0 +1,305 @@ +// Proof through the real gateway: the Lazurio workspace gateway (Machines' +// workspaceIngress rendered into a Caddyfile, run by the pinned Caddy) in +// front of two Apps, one reading `appAccess` (TypeScript) and one reading +// `app_access` (Python). It shows that a request through the gateway, with +// the App's hostname in any letter case, with a port or a trailing dot, +// counts only by the gateway's statement: a person is `environment`/admin, an +// admitted session without a person is `unknown`, a client's forged statement +// never counts, and no such request is ever `local`. +// +// bun proof/gateway.ts (CADDY_BIN, default `caddy`) +// +// Not part of `bun test`: it needs a Machines checkout, Caddy, OpenSSL and uv. +import { spawn, spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { appAccess } from "../src/index.ts"; + +const machines = process.argv[2]; +if (!machines) + throw new Error("usage: bun proof/gateway.ts "); +const caddy = process.env.CADDY_BIN ?? "caddy"; +const kit = join(import.meta.dir, ".."); +const dir = mkdtempSync(join(tmpdir(), "module-kit-gateway-")); +const DOMAIN = "pilot.example.lazurio.io"; +const ALENA = "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"; +const children: ReturnType[] = []; + +const freePort = () => + new Promise((resolve) => { + const server = createServer().listen(0, "127.0.0.1", () => { + const address = server.address(); + server.close(() => + resolve(typeof address === "object" && address ? address.port : 0), + ); + }); + }); +const fail = (message: string): never => { + throw new Error(message); +}; + +try { + const [auth, launchpad, tsPort, pyPort, https] = await Promise.all([ + freePort(), + freePort(), + freePort(), + freePort(), + freePort(), + ]); + const cert = spawnSync("openssl", [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-days", + "1", + "-subj", + `/CN=*.${DOMAIN}`, + "-addext", + `subjectAltName=DNS:*.${DOMAIN}`, + "-keyout", + join(dir, "key.pem"), + "-out", + join(dir, "cert.pem"), + ]); + if (cert.status !== 0) fail(`openssl: ${cert.stderr}`); + + // Stand-in oauth2-proxy /oauth2/auth: `alena` is a person, `nobody` an + // admitted session whose answer names no person, anything else refused. + Bun.serve({ + hostname: "127.0.0.1", + port: auth, + fetch(request) { + const session = /__Secure-lazurio-workspace=([a-z]+)/.exec( + request.headers.get("cookie") ?? "", + )?.[1]; + if (session === "alena") + return new Response(null, { + status: 202, + headers: { + "X-Auth-Request-User": ALENA, + "X-Auth-Request-Preferred-Username": "alena", + }, + }); + if (session === "nobody") return new Response(null, { status: 202 }); + return new Response(null, { status: 401 }); + }, + }); + // Stand-in Launchpad: every module is ready. + Bun.serve({ + hostname: "127.0.0.1", + port: launchpad, + fetch: () => new Response(null, { status: 204 }), + }); + + const origin = (slug: string) => `https://${slug}.${DOMAIN}`; + // The TypeScript App. + const tsEnv = { + LAZURIO_RUNTIME_LISTENER_APP_HOST: "127.0.0.1", + LAZURIO_RUNTIME_LISTENER_APP_PORT: String(tsPort), + LAZURIO_RUNTIME_LISTENER_APP_EXTERNAL_ORIGIN: origin("ts"), + }; + let reached = 0; + Bun.serve({ + hostname: "127.0.0.1", + port: tsPort, + fetch(request) { + reached++; + return Response.json(appAccess(request, "app", tsEnv)); + }, + }); + // The Python App: the request's header pairs as sent, repeats included. + const python = ` +import dataclasses, json, sys +from http.server import BaseHTTPRequestHandler, HTTPServer +from lazurio_module_kit import app_access +class Pairs: + def __init__(self, pairs): self.pairs = pairs + def items(self): return self.pairs +env = {"LAZURIO_RUNTIME_LISTENER_APP_HOST": "127.0.0.1", "LAZURIO_RUNTIME_LISTENER_APP_PORT": sys.argv[1], + "LAZURIO_RUNTIME_LISTENER_APP_EXTERNAL_ORIGIN": sys.argv[2]} +class App(BaseHTTPRequestHandler): + def do_GET(self): + answer = dataclasses.asdict(app_access(Pairs(list(self.headers.items())), "app", env)) + body = json.dumps(answer).encode() + self.send_response(200); self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(body))); self.end_headers(); self.wfile.write(body) + def log_message(self, *args): pass +HTTPServer(("127.0.0.1", int(sys.argv[1])), App).serve_forever() +`; + children.push( + spawn( + "uv", + [ + "run", + "--project", + join(kit, "python"), + "python", + "-c", + python, + String(pyPort), + origin("py"), + ], + { stdio: "inherit" }, + ), + ); + + const { workspaceIngress } = (await import( + join(machines, "workloads/workspace-vm/ingress.ts") + )) as { workspaceIngress: (config: unknown, options?: unknown) => string }; + let caddyfile = workspaceIngress( + { + machine: "pilot", + organization: "example", + applications: [{ slug: "launchpad", port: launchpad }], + }, + { authPort: auth }, + ); + caddyfile = caddyfile + .replace( + /admin unix\/\S+/, + `admin off\n https_port ${https}\n http_port ${await freePort()}\n auto_https disable_certs\n storage file_system ${join(dir, "data")}`, + ) + .replace(":443 {", `:${https} {`) + .replaceAll( + "tls /run/lazurio/tls/cert.pem /run/lazurio/tls/key.pem", + `tls ${join(dir, "cert.pem")} ${join(dir, "key.pem")}`, + ) + .replace(/^import \/etc\S*\n/m, "") + .replace( + /# BEGIN local entry[\s\S]*?# END local entry of the Environment browser\n/g, + "", + ); + caddyfile += `\nimport lazurio_module ts.${DOMAIN} 127.0.0.1:${tsPort} ts\nimport lazurio_module py.${DOMAIN} 127.0.0.1:${pyPort} py\n`; + writeFileSync(join(dir, "Caddyfile"), caddyfile); + children.push( + spawn( + caddy, + ["run", "--config", join(dir, "Caddyfile"), "--adapter", "caddyfile"], + { + stdio: ["ignore", "ignore", "inherit"], + env: { + ...process.env, + XDG_DATA_HOME: join(dir, "data"), + XDG_CONFIG_HOME: join(dir, "config"), + }, + }, + ), + ); + + // Asynchronous: the TypeScript App answers from this very process. + const get = async (slug: string, host: string, cookie: string | null) => { + const name = `${slug}.${DOMAIN}`; + const args = [ + "-sk", + "--max-time", + "10", + "--resolve", + `${name}:${https}:127.0.0.1`, + "-H", + `Host: ${host}`, + "-H", + "Sec-Fetch-Mode: navigate", + // A client's own statement: the gateway removes it. + "-H", + "X-Auth-Request-User: 0b6f1c2e-6d3a-4f5b-9c8d-7e6f5a4b3c2d", + "-H", + "X-Lazurio-Entry: app", + "-H", + "X-Lazurio-App-Role: admin", + ...(cookie ? ["-H", `Cookie: __Secure-lazurio-workspace=${cookie}`] : []), + "-w", + "\n%{http_code}", + `https://${name}:${https}/`, + ]; + const curl = Bun.spawn(["curl", ...args], { stdout: "pipe" }); + const out = await new Response(curl.stdout).text(); + await curl.exited; + const cut = out.lastIndexOf("\n"); + return { status: Number(out.slice(cut + 1)), body: out.slice(0, cut) }; + }; + // Wait for Caddy and the Python App. + for (let i = 0; ; i++) { + if ( + (await get("py", `py.${DOMAIN}`, "alena")).status === 200 && + (await get("ts", `ts.${DOMAIN}`, "alena")).status === 200 + ) + break; + if (i > 100) fail("the gateway or an App did not come up"); + await Bun.sleep(200); + } + + const results: string[] = []; + for (const slug of ["ts", "py"]) { + const name = `${slug}.${DOMAIN}`; + for (const host of [ + name, + name.toUpperCase(), + `${name}:443`, + `${name}:${https}`, + `${name}.`, + `${name.toUpperCase()}.:443`, + ]) { + const before = reached; + const first = await get(slug, host, "alena"); + if (first.status !== 200) { + // The gateway answers itself (Caddy refuses a Host that does not + // match the TLS name, 421): the App is never reached. + if (first.status === 200 || (slug === "ts" && reached !== before)) + fail( + `${slug} ${host}: refused with ${first.status} yet reached the App`, + ); + results.push( + `${slug} ${host}: refused by the gateway (${first.status})`, + ); + continue; + } + const person = JSON.parse(first.body); + const role = slug === "ts" ? person.appRole : person.app_role; + if ( + person.entry !== "environment" || + role !== "admin" || + person.person?.subject !== ALENA || + person.source !== "gateway" + ) + fail( + `${slug} ${host}: a person through the gateway reads ${JSON.stringify(person)}`, + ); + const second = await get(slug, host, "nobody"); + if (second.status !== 200) + fail( + `${slug} ${host}: a session without a person got ${second.status}`, + ); + const nobody = JSON.parse(second.body); + const none = slug === "ts" ? nobody.appRole : nobody.app_role; + if ( + nobody.entry !== "unknown" || + none !== null || + nobody.reason !== "no-identity" + ) + fail( + `${slug} ${host}: a session without a person reads ${JSON.stringify(nobody)}`, + ); + const counted = reached; + const refused = await get(slug, host, null); + if (refused.status === 200 || (slug === "ts" && reached !== counted)) + fail( + `${slug} ${host}: a request without a session reached the App (${refused.status})`, + ); + results.push( + `${slug} ${host}: environment/admin with a person, unknown without one, refused without a session`, + ); + } + } + console.log(results.join("\n")); + console.log( + `PASS: through the real gateway (${caddy}), for ${results.length} Host forms of a TypeScript and a Python App: a person is environment/admin from the gateway's answer, an admitted session without a person is unknown with no role, a client's forged statement never counts, no request is local, and without a session nothing reaches the App`, + ); +} finally { + for (const child of children) child.kill("SIGTERM"); + rmSync(dir, { recursive: true, force: true }); + setTimeout(() => process.exit(process.exitCode ?? 0), 200); +} diff --git a/python/pyproject.toml b/python/pyproject.toml index 8638ba6..2322726 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "lazurio-module-kit" -version = "0.1.0" -description = "Lazurio Module Standard runtime helpers: listener, health" +version = "0.2.0" +description = "Lazurio Module Standard runtime helpers: listener, health, app access" license = "MIT" requires-python = ">=3.11" dependencies = [] diff --git a/python/src/lazurio_module_kit/__init__.py b/python/src/lazurio_module_kit/__init__.py index 8af20d1..716f19c 100644 --- a/python/src/lazurio_module_kit/__init__.py +++ b/python/src/lazurio_module_kit/__init__.py @@ -13,14 +13,20 @@ import re from collections.abc import Awaitable, Callable, Mapping from dataclasses import dataclass +from types import MappingProxyType from typing import Literal, NamedTuple from urllib.parse import urlsplit __all__ = [ + "GATEWAY_HEADERS", + "AppAccess", + "AppPerson", "HealthResponse", "Listener", "ModuleKitError", + "app_access", "health", + "is_lazurio_username", "listener", "listener_variable_key", ] @@ -127,3 +133,139 @@ async def check() -> HealthResponse: return HealthResponse(200 if ok else 503, body, "application/json") return check + + +GATEWAY_HEADERS: Mapping[str, str] = MappingProxyType( + { + "subject": "x-auth-request-user", + "username": "x-auth-request-preferred-username", + "entry": "x-lazurio-entry", + "app_role": "x-lazurio-app-role", + } +) +"""How the Environment's gateway carries its statement to the App. Internal +transport, not a contract: only ``app_access`` reads it. Exported for a +Module's own tests.""" + +_SUBJECT = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") + +Entry = Literal["environment", "app", "local", "unknown"] +AppRole = Literal["admin", "user"] +Source = Literal["gateway", "auth"] +Reason = Literal["not-hosted", "not-through-gateway", "local-entry", "no-identity"] + + +@dataclass(frozen=True) +class AppPerson: + """A person the Environment's gateway admitted: the account's subject (a + lowercase UUID, the key to remember them by) and the Lazurio username to + show, or ``None``.""" + + subject: str + username: str | None + + +@dataclass(frozen=True) +class AppAccess: + """Who came to this App and with which application role (decision 0231); + the same contract as ``appAccess`` of the TypeScript package, with the + same five fields in every answer. + + ``entry`` is ``environment`` (the whole Environment, so ``app_role`` is + ``admin``), ``app`` (this App only, with the role the Organization gave), + ``local`` (a request from inside the Environment: an application admin + without a name) or ``unknown`` (no role). With a person ``source`` says + who stated it and ``reason`` is ``None``; without one ``source`` is + ``None`` and ``reason`` says why. Fields and values never change meaning + or disappear.""" + + entry: Entry + app_role: AppRole | None + person: AppPerson | None + source: Source | None + reason: Reason | None + + +def is_lazurio_username(value: object) -> bool: + """3 to 39 lowercase letters and digits with single dashes between them, + never shaped like a UUID and never a service account's.""" + return ( + isinstance(value, str) + and 3 <= len(value) <= 39 + and re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", value) is not None + and _SUBJECT.fullmatch(value.lower()) is None + and not value.startswith("service-account-") + ) + + +class _Repeated: + """A header sent more than once: never one valid value.""" + + +_REPEATED = _Repeated() + + +def _header(headers: Mapping[str, str], name: str) -> str | _Repeated | None: + """One header, matched case-insensitively: its value, ``None`` when absent, + ``_REPEATED`` when sent more than once.""" + values = [value for key, value in headers.items() if key.lower() == name] + if not values: + return None + return values[0] if len(values) == 1 else _REPEATED + + +_HOST = re.compile(r"(\[[^\]]*\]|[^:\[\]]*)(?::[0-9]*)?") +_LOOPBACK_HOST = re.compile(r"127(?:\.[0-9]{1,3}){3}|localhost|\[::1\]") + + +def _hostname(host: str | _Repeated | None) -> str | None: + """The hostname a Host header names, lowercased, without a port and a + trailing dot, as the gateway matches it; ``None`` when it names none.""" + if not isinstance(host, str): + return None + match = _HOST.fullmatch(host.strip()) + name = match.group(1).lower().rstrip(".") if match else "" + return name or None + + +def app_access( + headers: Mapping[str, str], + listener_id: str = "app", + env: Mapping[str, str] | None = None, +) -> AppAccess: + """Who came to this App and with which application role, for one request; + the same rules as ``appAccess`` of the TypeScript package. Without an + external origin every request is ``local``. A request whose Host names the + external origin's hostname (any letter case, any port) counts only by the + gateway's statement, and a missing, repeated or malformed statement is + ``unknown``, never a role. A loopback Host is ``local``; any other Host is + ``unknown``. Read it per request, never cache it. Raises + ``listener-missing`` like ``listener``.""" + origin = listener(listener_id, env).external_origin + unknown = AppAccess("unknown", None, None, None, "no-identity") + if origin is None: + return AppAccess("local", "admin", None, None, "not-hosted") + host = _hostname(_header(headers, "host")) + if host != _hostname(urlsplit(origin).netloc): + if host is not None and _LOOPBACK_HOST.fullmatch(host): + return AppAccess("local", "admin", None, None, "not-through-gateway") + return unknown + entry = _header(headers, GATEWAY_HEADERS["entry"]) + subject = _header(headers, GATEWAY_HEADERS["subject"]) + username = _header(headers, GATEWAY_HEADERS["username"]) + role = _header(headers, GATEWAY_HEADERS["app_role"]) + if entry == "local" and subject is None and username is None and role is None: + return AppAccess("local", "admin", None, None, "local-entry") + if not isinstance(subject, str) or _SUBJECT.fullmatch(subject) is None: + return unknown + person = AppPerson( + subject, username if isinstance(username, str) and is_lazurio_username(username) else None + ) + if entry == "environment": + return AppAccess("environment", "admin", person, "gateway", None) + if entry == "app": + if role == "admin": + return AppAccess("app", "admin", person, "gateway", None) + if role == "user": + return AppAccess("app", "user", person, "gateway", None) + return unknown diff --git a/python/tests/test_module_kit.py b/python/tests/test_module_kit.py index 05404d3..14115b5 100644 --- a/python/tests/test_module_kit.py +++ b/python/tests/test_module_kit.py @@ -1,12 +1,18 @@ import asyncio import json +from collections.abc import Awaitable, Callable +from typing import Literal import pytest from lazurio_module_kit import ( + GATEWAY_HEADERS, + AppAccess, + AppPerson, HealthResponse, Listener, ModuleKitError, + app_access, health, listener, listener_variable_key, @@ -76,8 +82,11 @@ def test_reads_process_environment(monkeypatch: pytest.MonkeyPatch) -> None: assert listener("app").port == 43100 -def _run(ready: object) -> HealthResponse: - return asyncio.run(health(ready)()) # type: ignore[arg-type] +def _run(ready: Callable[[], bool | Awaitable[bool]]) -> HealthResponse: + async def check() -> HealthResponse: + return await health(ready)() + + return asyncio.run(check()) def test_health_ok() -> None: @@ -101,3 +110,141 @@ def broken() -> bool: raise RuntimeError("db down") assert _run(broken).status == 503 + + +HOST = "deals.vm-01.example.lazurio.io" +HOSTED = {**APP, "LAZURIO_RUNTIME_LISTENER_APP_EXTERNAL_ORIGIN": f"https://{HOST}"} +ALENA = "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" +BOB = "9a8b7c6d-5e4f-4321-8fed-cba987654321" +UNKNOWN = AppAccess("unknown", None, None, None, "no-identity") + + +def _local(reason: Literal["not-hosted", "not-through-gateway", "local-entry"]) -> AppAccess: + return AppAccess("local", "admin", None, None, reason) + + +def _person(subject: str = ALENA, entry: str = "environment", host: str = HOST) -> dict[str, str]: + return { + "host": host, + GATEWAY_HEADERS["subject"]: subject, + GATEWAY_HEADERS["username"]: "alena", + GATEWAY_HEADERS["entry"]: entry, + } + + +def test_whole_environment_is_an_application_admin() -> None: + headers = { + "Host": HOST, + "X-Auth-Request-User": ALENA, + "X-Auth-Request-Preferred-Username": "alena", + "X-Lazurio-Entry": "environment", + } + assert app_access(headers, "app", HOSTED) == AppAccess( + "environment", "admin", AppPerson(ALENA, "alena"), "gateway", None + ) + + +@pytest.mark.parametrize("role", ["admin", "user"]) +def test_this_app_only_has_the_stated_role(role: Literal["admin", "user"]) -> None: + headers = {**_person(entry="app"), GATEWAY_HEADERS["app_role"]: role} + assert app_access(headers, "app", HOSTED) == AppAccess( + "app", role, AppPerson(ALENA, "alena"), "gateway", None + ) + + +@pytest.mark.parametrize("role", [None, "", "Admin", "owner"]) +def test_this_app_only_without_a_valid_role_is_unknown(role: str | None) -> None: + headers = ( + _person(entry="app") if role is None else {**_person(entry="app"), GATEWAY_HEADERS["app_role"]: role} + ) + assert app_access(headers, "app", HOSTED) == UNKNOWN + + +@pytest.mark.parametrize( + "host", + [ + "DEALS.VM-01.EXAMPLE.LAZURIO.IO", + f"{HOST}:443", + f"{HOST}:8443", + f"{HOST}.", + "Deals.Vm-01.Example.Lazurio.Io.:443", + ], +) +def test_the_gateway_hostname_in_any_form_counts_only_by_its_statement(host: str) -> None: + visitor = {**_person(entry="app", host=host), GATEWAY_HEADERS["app_role"]: "user"} + assert app_access(visitor, "app", HOSTED).app_role == "user" + assert app_access({"host": host}, "app", HOSTED) == UNKNOWN + + +def test_not_hosted_is_local_whatever_it_carries() -> None: + headers = {**_person(entry="app", host="127.0.0.1:43100"), GATEWAY_HEADERS["app_role"]: "user"} + assert app_access(headers, "app", APP) == _local("not-hosted") + + +@pytest.mark.parametrize( + "host", ["127.0.0.1:43100", "127.0.0.1", "localhost:43100", "LOCALHOST", "[::1]:43100"] +) +def test_a_loopback_host_is_local(host: str) -> None: + assert app_access(_person(host=host), "app", HOSTED) == _local("not-through-gateway") + + +@pytest.mark.parametrize( + "host", ["notes.vm-01.example.lazurio.io", f"{HOST}.evil.test", "evil.test", "", "127.0.0.1.evil.test"] +) +def test_any_other_host_is_unknown(host: str) -> None: + assert app_access(_person(host=host), "app", HOSTED) == UNKNOWN + + +def test_local_entry_is_local_without_a_name() -> None: + headers = {"host": HOST, GATEWAY_HEADERS["entry"]: "local"} + assert app_access(headers, "app", HOSTED) == _local("local-entry") + + +@pytest.mark.parametrize( + "headers", + [ + {"host": HOST}, + {"host": HOST, GATEWAY_HEADERS["subject"]: ALENA}, + {**_person(), GATEWAY_HEADERS["entry"]: "full"}, + {**_person(), GATEWAY_HEADERS["entry"]: "local"}, + _person(""), + _person(ALENA.upper()), + _person(ALENA + "0"), + _person("alena"), + {"host": HOST, GATEWAY_HEADERS["entry"]: "local", GATEWAY_HEADERS["username"]: "alena"}, + {"host": HOST, GATEWAY_HEADERS["entry"]: "local", GATEWAY_HEADERS["app_role"]: "user"}, + ], +) +def test_missing_or_malformed_statement_is_unknown(headers: dict[str, str]) -> None: + assert app_access(headers, "app", HOSTED) == UNKNOWN + + +def test_repeated_subject_is_unknown() -> None: + headers = {**_person(), "X-Auth-Request-User": ALENA} + assert app_access(headers, "app", HOSTED) == UNKNOWN + + +def test_repeated_subject_with_a_local_entry_is_unknown() -> None: + headers = { + "host": HOST, + GATEWAY_HEADERS["entry"]: "local", + "x-auth-request-user": ALENA, + "X-Auth-Request-User": BOB, + } + assert app_access(headers, "app", HOSTED) == UNKNOWN + + +def test_repeated_local_entry_is_unknown() -> None: + headers = {"host": HOST, "x-lazurio-entry": "local", "X-Lazurio-Entry": "local"} + assert app_access(headers, "app", HOSTED) == UNKNOWN + + +def test_repeated_host_is_unknown() -> None: + headers = {**_person(), "Host": HOST} + assert app_access(headers, "app", HOSTED) == UNKNOWN + + +@pytest.mark.parametrize("username", ["Alena", "al", "a" * 40, "-alena", ALENA, "service-account-pilot"]) +def test_invalid_username_is_none(username: str) -> None: + headers = {**_person(), GATEWAY_HEADERS["username"]: username} + assert app_access(headers, "app", HOSTED).person == AppPerson(ALENA, None) diff --git a/python/uv.lock b/python/uv.lock index 5cdae00..88ba742 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.11" [[package]] @@ -22,7 +22,7 @@ wheels = [ [[package]] name = "lazurio-module-kit" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } [package.dev-dependencies] diff --git a/src/index.ts b/src/index.ts index a16bde8..95fe1e1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -338,3 +338,185 @@ export function requireEnvironment( string >; } + +/** A person the Environment's gateway admitted. */ +export type AppPerson = Readonly<{ + /** The Lazurio account's subject, a lowercase UUID: stable for the + * account, the key to remember a person by. */ + subject: string; + /** The person's Lazurio username, to show; null when the gateway's answer + * named none. */ + username: string | null; +}>; + +/** Who came to this App and with which application role (decision 0231). + * The stable contract of Lazurio for a Module: its fields and values never + * change meaning or disappear, and new information comes as new fields. + * + * - `environment`: the person has the whole Environment where the App runs, + * so they are an application admin. + * - `app`: the person has this App only, with the application role the + * Organization gave them. + * - `local`: a request from inside the Environment (a person's own computer + * with no gateway, a sibling or a script on the Environment, or the + * Environment browser's local entry): an application admin without a name. + * - `unknown`: a request that came through the gateway without identity + * (a gateway without this capability): no application role at all. + * + * What application admin and application user may do is the Organization's + * rule in the Module's code, enforced on the server for every request. The + * answer is no key to data and signs nothing. */ +export type AppAccess = + | Readonly<{ + entry: "environment"; + appRole: "admin"; + person: AppPerson; + /** Who states it: the Environment's gateway, or later an identity + * signed by Lazurio Auth for use outside the Environment. */ + source: "gateway" | "auth"; + reason: null; + }> + | Readonly<{ + entry: "app"; + appRole: "admin" | "user"; + person: AppPerson; + source: "gateway" | "auth"; + reason: null; + }> + | Readonly<{ + entry: "local"; + appRole: "admin"; + person: null; + /** No person was stated, so nobody stated it. */ + source: null; + reason: "not-hosted" | "not-through-gateway" | "local-entry"; + }> + | Readonly<{ + entry: "unknown"; + appRole: null; + person: null; + source: null; + reason: "no-identity"; + }>; + +/** What `appAccess` reads of a request: a `Request` or its headers. */ +export type RequestHeaders = Readonly<{ get(name: string): string | null }>; + +/** How the Environment's gateway carries its statement to the App. Internal + * transport, not a contract: only `appAccess` reads it, and it may change + * without changing `appAccess`. Exported for a Module's own tests. */ +export const GATEWAY_HEADERS = Object.freeze({ + subject: "x-auth-request-user", + username: "x-auth-request-preferred-username", + entry: "x-lazurio-entry", + appRole: "x-lazurio-app-role", +}); + +const SUBJECT = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const UUID_ANY_CASE = new RegExp(SUBJECT.source, "i"); + +/** A Lazurio username: 3 to 39 lowercase letters and digits with single + * dashes between them, never shaped like a UUID and never a service + * account's (`service-account-…`). */ +export function isLazurioUsername(value: unknown): value is string { + return ( + typeof value === "string" && + value.length >= 3 && + value.length <= 39 && + /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value) && + !UUID_ANY_CASE.test(value) && + !value.startsWith("service-account-") + ); +} + +/** The hostname a `Host` header names, lowercased, without a port and a + * trailing dot, as the gateway matches it; null when it names none. */ +function hostnameOf(host: string | null): string | null { + if (host === null) return null; + const match = /^(\[[^\]]*\]|[^:[\]]*)(?::[0-9]*)?$/.exec(host.trim()); + const name = match?.[1]?.toLowerCase().replace(/\.$/, "") ?? ""; + return name === "" ? null : name; +} + +const LOOPBACK_HOST = /^(?:127(?:\.[0-9]{1,3}){3}|localhost|\[::1\])$/; + +/** Who came to this App and with which application role, for one request + * (decision 0231; Lazurio Module Standard, "Who came and with which role"). + * + * The answer has the same five fields in every case. Without an external + * origin (`LAZURIO_RUNTIME_LISTENER__EXTERNAL_ORIGIN`) no gateway stands + * in front of the App and every request is `local`. With one, a request whose + * `Host` names the external origin's hostname, in any letter case and with + * any port, as the gateway matches it, counts only by the gateway's statement: + * a missing, repeated or malformed statement is `unknown`, never a role. A + * request to a loopback address (a sibling, a script, an agent) is `local`; + * any other `Host` is `unknown`. The gateway removes every client copy and + * admits every request again, so read it per request and never cache it. + * Inside the Environment the answer holds as far as the Environment does: a + * process on it that reaches the App's loopback port could send anything, as + * it could read the App's data (decision 0196), so it is no authentication + * boundary inside the Environment. Throws `listener-missing` like + * `listener(id)`. */ +export function appAccess( + request: Request | RequestHeaders, + id = "app", + env: Environment = process.env, +): AppAccess { + const { externalOrigin } = listener(id, env); + const local = ( + reason: "not-hosted" | "not-through-gateway" | "local-entry", + ): AppAccess => ({ + entry: "local", + appRole: "admin", + person: null, + source: null, + reason, + }); + const unknown: AppAccess = { + entry: "unknown", + appRole: null, + person: null, + source: null, + reason: "no-identity", + }; + if (externalOrigin === null) return local("not-hosted"); + const headers: RequestHeaders = + "headers" in request ? request.headers : request; + const host = hostnameOf(headers.get("host")); + if (host !== hostnameOf(new URL(externalOrigin).host)) + return host !== null && LOOPBACK_HOST.test(host) + ? local("not-through-gateway") + : unknown; + // Through the gateway: only its statement counts. A repeated header reads + // as its values joined with a comma, so it never matches one valid value. + const entry = headers.get(GATEWAY_HEADERS.entry); + const subject = headers.get(GATEWAY_HEADERS.subject); + if ( + entry === "local" && + subject === null && + headers.get(GATEWAY_HEADERS.username) === null && + headers.get(GATEWAY_HEADERS.appRole) === null + ) + return local("local-entry"); + if (subject === null || !SUBJECT.test(subject)) return unknown; + const username = headers.get(GATEWAY_HEADERS.username); + const person: AppPerson = { + subject, + username: isLazurioUsername(username) ? username : null, + }; + if (entry === "environment") + return { + entry: "environment", + appRole: "admin", + person, + source: "gateway", + reason: null, + }; + if (entry === "app") { + const appRole = headers.get(GATEWAY_HEADERS.appRole); + if (appRole === "admin" || appRole === "user") + return { entry: "app", appRole, person, source: "gateway", reason: null }; + } + return unknown; +} diff --git a/test/index.test.ts b/test/index.test.ts index f6b4dd8..189face 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2,8 +2,11 @@ import { describe, expect, test } from "bun:test"; import { EventEmitter } from "node:events"; import { allowedHosts, + appAccess, astroServerOptions, + GATEWAY_HEADERS, health, + isLazurioUsername, listener, listeners, listenerVariableKey, @@ -491,3 +494,244 @@ test("viteShutdownPlugin exits 0 as soon as close() resolves", async () => { await new Promise((resolve) => setTimeout(resolve, 20)); expect(fake.exits).toEqual([0]); }); + +describe("appAccess", () => { + const HOST = "deals.vm-01.example.lazurio.io"; + const ALENA = "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"; + const BOB = "9a8b7c6d-5e4f-4321-8fed-cba987654321"; + const through = (extra: Record = {}, host = HOST) => + new Request(`https://${HOST}/api/me`, { + headers: { host, ...extra }, + }); + const person = (subject = ALENA, entry = "environment") => ({ + [GATEWAY_HEADERS.subject]: subject, + [GATEWAY_HEADERS.username]: "alena", + [GATEWAY_HEADERS.entry]: entry, + }); + const unknown = { + entry: "unknown", + appRole: null, + person: null, + source: null, + reason: "no-identity", + } as const; + const local = ( + reason: "not-hosted" | "not-through-gateway" | "local-entry", + ) => + ({ + entry: "local", + appRole: "admin", + person: null, + source: null, + reason, + }) as const; + + test("the whole Environment: an application admin with the person", () => { + expect(appAccess(through(person()), "app", HOSTED)).toEqual({ + entry: "environment", + appRole: "admin", + person: { subject: ALENA, username: "alena" }, + source: "gateway", + reason: null, + }); + }); + + test("this App only: the application role the gateway stated", () => { + for (const appRole of ["admin", "user"] as const) + expect( + appAccess( + through({ + ...person(ALENA, "app"), + [GATEWAY_HEADERS.appRole]: appRole, + }), + "app", + HOSTED, + ), + ).toEqual({ + entry: "app", + appRole, + person: { subject: ALENA, username: "alena" }, + source: "gateway", + reason: null, + }); + }); + + test("every answer has the same five fields", () => { + for (const answer of [ + appAccess(through(person()), "app", HOSTED), + appAccess(through(), "app", HOSTED), + appAccess(through({}, "127.0.0.1:43100"), "app", HOSTED), + appAccess(through(), "app", APP), + ]) + expect(Object.keys(answer).sort()).toEqual([ + "appRole", + "entry", + "person", + "reason", + "source", + ]); + }); + + test("this App only without a valid role is unknown, never a role", () => { + for (const role of [undefined, "", "Admin", "owner", "admin, user"]) + expect( + appAccess( + through({ + ...person(ALENA, "app"), + ...(role === undefined ? {} : { [GATEWAY_HEADERS.appRole]: role }), + }), + "app", + HOSTED, + ), + ).toEqual(unknown); + }); + + test("reads plain headers too (any server)", () => { + const headers = new Headers({ host: HOST, ...person() }); + expect(appAccess(headers, "app", HOSTED)).toMatchObject({ + entry: "environment", + appRole: "admin", + }); + }); + + test("the gateway's hostname in any letter case, with a port or a trailing dot, counts only by its statement", () => { + for (const host of [ + "DEALS.VM-01.EXAMPLE.LAZURIO.IO", + `${HOST}:443`, + `${HOST}:8443`, + `${HOST}.`, + `Deals.Vm-01.Example.Lazurio.Io.:443`, + ]) { + // A visitor with this App only stays a user, never a local admin. + expect( + appAccess( + through( + { ...person(ALENA, "app"), [GATEWAY_HEADERS.appRole]: "user" }, + host, + ), + "app", + HOSTED, + ), + ).toMatchObject({ entry: "app", appRole: "user" }); + // Without a statement it is unknown, never local. + expect(appAccess(through({}, host), "app", HOSTED)).toEqual(unknown); + } + }); + + test("without a gateway every request is local, whatever it carries", () => { + const forged = new Request("http://127.0.0.1:43100/", { + headers: { + host: "127.0.0.1:43100", + ...person(ALENA, "app"), + [GATEWAY_HEADERS.appRole]: "user", + }, + }); + expect(appAccess(forged, "app", APP)).toEqual(local("not-hosted")); + }); + + test("a request to a loopback address is local", () => { + for (const host of [ + "127.0.0.1:43100", + "127.0.0.1", + "localhost:43100", + "LOCALHOST", + "[::1]:43100", + ]) + expect( + appAccess( + new Request("http://127.0.0.1:43100/", { + headers: { host, ...person() }, + }), + "app", + HOSTED, + ), + ).toEqual(local("not-through-gateway")); + }); + + test("any other Host is unknown, never local", () => { + for (const host of [ + "notes.vm-01.example.lazurio.io", + "deals.vm-01.example.lazurio.io.evil.test", + "evil.test", + "", + "127.0.0.1.evil.test", + ]) + expect(appAccess(through(person(), host), "app", HOSTED)).toEqual( + unknown, + ); + }); + + test("the Environment browser's local entry is local without a name", () => { + expect( + appAccess(through({ [GATEWAY_HEADERS.entry]: "local" }), "app", HOSTED), + ).toEqual(local("local-entry")); + }); + + test("through the gateway, a missing, repeated or malformed statement is unknown", () => { + const cases: Record[] = [ + {}, + { [GATEWAY_HEADERS.subject]: ALENA }, + { ...person(), [GATEWAY_HEADERS.entry]: "full" }, + { ...person(), [GATEWAY_HEADERS.entry]: "local" }, + { ...person(""), [GATEWAY_HEADERS.entry]: "environment" }, + person(ALENA.toUpperCase()), + person(`${ALENA}0`), + person("alena"), + person(`${ALENA}, ${BOB}`), + { ...person(), [GATEWAY_HEADERS.entry]: "environment, app" }, + { [GATEWAY_HEADERS.entry]: "local, local" }, + { + [GATEWAY_HEADERS.entry]: "local", + [GATEWAY_HEADERS.username]: "alena", + }, + { + [GATEWAY_HEADERS.entry]: "local", + [GATEWAY_HEADERS.appRole]: "user", + }, + ]; + for (const headers of cases) + expect(appAccess(through(headers), "app", HOSTED)).toEqual(unknown); + // A repeated subject next to a local entry is no local entry. + const repeated = new Headers({ host: HOST }); + repeated.append(GATEWAY_HEADERS.entry, "local"); + repeated.append(GATEWAY_HEADERS.subject, ALENA); + repeated.append(GATEWAY_HEADERS.subject, BOB); + expect(appAccess(repeated, "app", HOSTED)).toEqual(unknown); + }); + + test("a username outside the Lazurio rule is no username", () => { + for (const username of [ + "Alena", + "al", + "a".repeat(40), + "-alena", + "ale--na", + ALENA, + "service-account-pilot", + "alena, bob", + ]) + expect( + appAccess( + through({ ...person(), [GATEWAY_HEADERS.username]: username }), + "app", + HOSTED, + ).person, + ).toEqual({ subject: ALENA, username: null }); + expect(isLazurioUsername("bob-example")).toBe(true); + }); + + test("per request: another account on the next request is that person", () => { + expect(appAccess(through(person()), "app", HOSTED).person).toMatchObject({ + subject: ALENA, + }); + expect(appAccess(through(person(BOB)), "app", HOSTED).person).toMatchObject( + { subject: BOB }, + ); + }); + + test("an App started outside Lazurio fails like listener()", () => { + expect(thrown(() => appAccess(through(), "app", {})).code).toBe( + "listener-missing", + ); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index cd25e36..7f3c7db 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,5 +14,5 @@ "types": ["bun"], "skipLibCheck": true }, - "include": ["src/**/*.ts", "test/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts", "proof/**/*.ts"] }