Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ jobs:
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}

# Atom implements a proto it does not own, and nothing rebuilds the
# vendored copy. Without this, an upstream change surfaces at runtime.
- name: Vendored proto matches upstream
run: scripts/check-vendored-proto.sh

- name: cargo fmt
run: cargo fmt --check

Expand Down
89 changes: 89 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ src/
│ RequireManage extractor + has_global_manage() helper
keys.rs — ES256 signing keys (primary/standby/retired), encryption at rest
grpc.rs — Tonic services: AuthService, AuthzService.Check, CertificateService
broker_auth/ — the broker auth callout: Atom serving FluxMQ's
│ `broker.auth.v1.AuthService` directly (off by default)
│ topic.rs — the configurable topic→object grammar
│ service.rs — Authenticate/Authorize over the existing credential + PDP paths
graphql/ — schema + per-domain resolvers (the live admin/API surface)
db.rs — pool creation (configurable pool)
models/
Expand Down Expand Up @@ -200,6 +204,14 @@ cargo test -- --include-ignored
# Lint
cargo clippy -- -D warnings
cargo fmt --check

# Protobuf. The Rust bindings are NOT checked in — build.rs runs tonic-build on
# every compile into cargo's OUT_DIR, so editing a .proto and rebuilding is
# enough for code. `make proto` also regenerates apidocs/grpc-reference.md,
# which IS checked in and goes stale silently without it.
make proto
make proto-lint # protos Atom owns
make proto-check # vendored broker contract vs upstream
```

Environment variables: copy `.env.example` to `.env`. Required: `DATABASE_URL`. Signing uses ES256 keys bootstrapped/loaded at startup — there is no `JWT_SECRET`. `ATOM_KEY_ENCRYPTION_KEY` is the single root AES-256-GCM key encrypting all recoverable secrets at rest (signing private keys and retrievable credential secrets such as shared keys); it is required to create shared keys.
Expand All @@ -221,6 +233,83 @@ must then be confined to a private network or a service mesh that provides
transport security, and a startup warning is logged. (The HTTP rate limiter does
not cover gRPC; see backlog #10.)

### Broker auth callout

A message broker delegates connect-time credential checks and per-topic access
control to an external gRPC service. Atom implements that contract itself
(`src/broker_auth/`), so a broker can be pointed straight at Atom with **no
adapter service in between**. The proto is vendored verbatim from FluxMQ at
`proto/broker/v1/auth.proto` as a **byte-identical** copy, so drift is a plain
`diff` — `scripts/check-vendored-proto.sh`, which CI runs against the ref pinned
in `proto/broker/v1/REF`. Atom's notes live beside it in `VENDOR.md`, never in
the proto itself: a check that has to forgive expected differences stops
catching the one that matters.

Because Atom does not own that file, it is excluded from `buf.yaml`'s lint and
breaking rules (its style is upstream's, and editing it would break the
byte-for-byte match) and from `buf.gen.yaml`'s inputs — `protoc-gen-doc` writes
one file per invocation, so including a second package does not extend
`apidocs/grpc-reference.md`, it **replaces** it and Atom's own gRPC surface
vanishes from the docs.

The `package broker.auth.v1` line **is** the contract — the path a broker dials
is derived from it. It is vendor-neutral on purpose: the messages carry no
FluxMQ concept, so naming the package after one implementation would put that
name in every peer's public wire surface. Changing it is a breaking wire change:
a broker dialling the new path against a service still serving the old one gets
`UNIMPLEMENTED`, so Atom, the broker, and any adapter service must be deployed
together.

Config (`ATOM_BROKER_*`), all optional:

| Variable | Default | Meaning |
|---|---|---|
| `ATOM_BROKER_AUTH_ENABLED` | `false` | mount the callout |
| `ATOM_BROKER_TOPIC_TEMPLATE` | `{resource}/#` | comma-separated templates, tried in order |
| `ATOM_BROKER_TOPIC_REF` | `alias` | `alias` or `uuid` — how a bound segment names an object |
| `ATOM_BROKER_CREDENTIAL_KIND` | `password` | `password` or `shared_key` |
| `ATOM_BROKER_TOPIC_ALLOW` | *(empty)* | comma-separated MQTT filters authorized **without consulting the PDP** |

`ATOM_BROKER_TOPIC_ALLOW` is the only authorization bypass in the callout. It
exists because brokers carry operational topics that address no object — a
health probe such as `hc/<tenant>` names nothing Atom can resolve, so no policy
could describe it and every request for it would be denied. Patterns are
ordinary MQTT filters (`+` one segment, `#` the remainder); use the narrowest
one that covers the topic, since `#` alone grants the broker everything. The
broker's topic is matched literally and a broker `#` is only admitted by a
pattern that is itself `#` at that position — otherwise `hc/+` would quietly
admit a subscription to the whole `hc` subtree.

**Off by default for a security reason, not a rollout one.** It is the only gRPC
service here with no bearer token to check — a broker's callout client cannot
send one — so it authenticates its caller at the transport, via
`ATOM_GRPC_TLS_CLIENT_CA_PATH`. Mounted on a plaintext listener, anything that
can reach the port can authenticate and authorize as any principal. Enable it
with a client CA that signs brokers and nothing else. A startup warning fires if
it is enabled without one.

Two invariants worth not relearning:

- **Denials are answers, not errors.** Every rejection — bad password, unknown
entity, unparseable topic, policy deny, *rate limit* — returns a successful RPC
carrying a false verdict. Only infrastructure failure returns a gRPC error. A
broker wraps this callout in a circuit breaker, and a tripped breaker rejects
**every** client connection; one device retrying a stale password must not be
able to take the broker's whole auth path down. Rate limiting is on that list
because it is the failure a bad client can trigger at will.
- **Tenant comes from the subject, not from config or the topic.** Authenticate
resolves the identifier across tenants and the entity's own tenant comes back
with it, so the zero-configuration case needs no tenant in the topic and no
username grammar. A `{tenant}` template segment, when present, only scopes
alias resolution — it is deliberately **not** checked against the subject's
tenant, because cross-tenant grants are legitimate and that call belongs to
the PDP, not to a hardcoded equality test.

An adapter service is still right where the mapping needs more than a grammar —
route resolution, multi-service composition. Both speak the same wire contract,
so a deployment picks one by pointing the broker's `auth.external.url` at Atom
or at the adapter.

## Metrics

Prometheus metrics are exposed at `GET /metrics` (text exposition). All metric
Expand Down
35 changes: 34 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ COMPOSE_ENV = ATOM_IMAGE="$(ATOM_IMAGE)" ATOM_UI_IMAGE="$(ATOM_UI_IMAGE)"
DEV_HTTP_PORT ?= 8090
DEV_UI_PORT ?= 3000

.PHONY: help db dev build latest release release-check atom-build docker_atom_dev ui-build up down logs restart docker-build docker-build-release
.PHONY: help db dev build latest release release-check atom-build docker_atom_dev ui-build up down logs restart docker-build docker-build-release proto proto-lint proto-check

help:
@echo "First run: cp .env.example .env"
Expand All @@ -46,6 +46,9 @@ help:
@echo " make db Start only Postgres (for host 'cargo run')"
@echo " make dev Postgres (Docker) + host cargo run (:$(DEV_HTTP_PORT)) + host UI (:$(DEV_UI_PORT)); runs alongside 'make up'"
@echo " make restart Restart the Compose stack (no rebuild; use 'make build' first)"
@echo " make proto Regenerate protobuf outputs (gRPC reference docs + Rust bindings)"
@echo " make proto-lint Lint the protos Atom owns"
@echo " make proto-check Verify the vendored broker contract still matches upstream"
@echo " make logs Follow Atom + Atom UI logs"
@echo " make down Stop the local Compose stack"
@echo " make docker-build Build the raw Atom Docker image for BUILD_TARGET"
Expand Down Expand Up @@ -170,3 +173,33 @@ docker-build:

docker-build-release:
$(MAKE) docker-build BUILD_TARGET=release IMAGE_TAG=release

# ─── Protobuf ─────────────────────────────────────────────────────────────────
#
# Atom has two protobuf outputs, and only one of them is a file in the repo:
#
# apidocs/grpc-reference.md — checked in, produced by `buf generate`
# the Rust service bindings — NOT checked in; build.rs runs tonic-build on
# every compile and writes into cargo's OUT_DIR
#
# So this target regenerates the docs and then rebuilds, which is what refreshes
# the bindings. Editing a .proto and running `cargo build` is enough on its own —
# tonic-build emits `cargo:rerun-if-changed` for each proto — but the docs are
# generated by buf and will silently go stale without this.
proto:
@command -v buf >/dev/null || { \
echo "buf not found — install from https://buf.build/docs/installation"; exit 1; }
@command -v protoc-gen-doc >/dev/null || { \
echo "protoc-gen-doc not found — go install github.com/pseudomuto/protoc-gen-doc/cmd/protoc-gen-doc@latest"; exit 1; }
buf generate
cargo build

# The vendored broker contract is excluded in buf.yaml: Atom does not own its
# style, and editing it would break the byte-for-byte match `proto-check` needs.
proto-lint:
buf lint

# Upstream owns proto/broker/v1/auth.proto. Nothing rebuilds it, so without this
# an upstream change surfaces at runtime. CI runs the same script.
proto-check:
scripts/check-vendored-proto.sh
10 changes: 10 additions & 0 deletions buf.gen.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
version: v2

# Only the protos Atom owns. proto/broker/v1/auth.proto is vendored verbatim
# from FluxMQ (see its VENDOR.md) and is deliberately excluded: protoc-gen-doc
# writes one file per invocation, so a second package here does not extend
# grpc-reference.md — it silently replaces it, and Atom's own gRPC surface
# disappears from the docs. The vendored contract is documented upstream.
inputs:
- directory: proto
exclude_paths:
- proto/broker

plugins:
# Generates apidocs/grpc-reference.md from the proto.
# Requires protoc-gen-doc: go install github.com/pseudomuto/protoc-gen-doc/cmd/protoc-gen-doc@latest
Expand Down
12 changes: 11 additions & 1 deletion buf.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,18 @@ lint:
- STANDARD
except:
- RPC_REQUEST_RESPONSE_UNIQUE
# proto/broker/v1/auth.proto is vendored verbatim from FluxMQ (see its
# VENDOR.md). Atom does not own its style and cannot fix it without breaking
# the byte-for-byte match the drift check depends on, so linting it would
# only produce failures no one here can act on.
ignore:
- proto/broker/v1

breaking:
use:
- FILE

# Same file, different reason: a change here is upstream's, and it is caught
# by scripts/check-vendored-proto.sh, which reports it as drift with the
# context needed to judge it.
ignore:
- proto/broker/v1
5 changes: 4 additions & 1 deletion build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("cargo:rustc-env=ATOM_VERSION={version}");
println!("cargo:rustc-env=ATOM_REVISION={revision}");

tonic_build::compile_protos("proto/atom/v1/atom.proto")?;
tonic_build::configure().compile_protos(
&["proto/atom/v1/atom.proto", "proto/broker/v1/auth.proto"],
&["proto"],
)?;
Ok(())
}

Expand Down
1 change: 1 addition & 0 deletions proto/broker/v1/REF
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
main
49 changes: 49 additions & 0 deletions proto/broker/v1/VENDOR.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Vendored broker-callout contract

`auth.proto` is a **byte-identical copy** of FluxMQ's
`proto/auth/v1/auth.proto`. Atom implements `AuthService` so a broker can call
it directly, with no adapter service in between.

| | |
| ---------- | --------------------------------- |
| Source | https://github.com/absmach/fluxmq |
| Path | `proto/auth/v1/auth.proto` |
| Pinned ref | see `REF` in this directory |

## Why it is byte-identical

Nothing Atom-specific belongs in this file. Drift from upstream is detected by a
plain `diff`, and a diff can only stay trustworthy if there is nothing expected
to differ — a locally-edited header would mean the check had to know which
differences to forgive, and a check that forgives differences stops catching the
one that matters. Atom's own notes live in this file and in
`AGENTS.md § Broker auth callout`.

## Checking for drift

```bash
scripts/check-vendored-proto.sh
```

CI runs the same script. It fetches the pinned ref from GitHub and diffs.

## When it fails

A failure means upstream changed the contract Atom implements. That is
information, not a chore — read the diff before syncing:

- **Comments or new optional fields** — re-vendor, bump `REF`, done.
- **A changed `package` line** — the gRPC path a broker dials is derived from
it, so this is a breaking wire change. Atom, the broker, and any adapter
service must move together; see the deployment note in `AGENTS.md`.
- **Renamed or renumbered fields** — check `src/broker_auth/service.rs` before
re-vendoring. `prost` will happily compile a field that now means something
else.

## Syncing

```bash
curl -fsSL "https://raw.githubusercontent.com/absmach/fluxmq/$(cat proto/broker/v1/REF)/proto/auth/v1/auth.proto" \
-o proto/broker/v1/auth.proto
cargo test --lib broker_auth
```
Loading
Loading