Skip to content
Closed
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
101 changes: 101 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Hilt

Tenant-management service for the Forge network. Hilt owns tenants, their access
keys, and their buckets — plus the UCAN delegations and key material that back
them. It exposes two APIs and talks to one external service:

- **Tenant REST API** (`pkg/api`, echo) — partner-facing CRUD for tenants and
access keys, guarded by a pre-shared partner key.
- **Hilt UCAN RPC API** (`pkg/rpc`, ucantone server mounted at `POST /`) — the
`/s3/*` commands Ingot (the S3 gateway) invokes: `/s3/request/authorize`,
`/s3/bucket/{create,delete,info,list}`.
- **Sprue** (the Forge upload service) — Hilt calls it to provision/inspect a
bucket's storage space (`pkg/client`).

Module: `github.com/fil-forge/hilt` (Go 1.26). Sibling repos it builds on:
`ucantone` (UCAN primitives: `did`, `multikey`, `ucan/delegation`, `binding`,
`server`, `execution`), `libforge` (bound `commands/*`, `identity`, ucan helpers),
and `sprue` (the upload service; mirror its patterns where relevant).

## Commands

- Build / vet / test: `go build ./... && go vet ./... && go test ./...`. Run all
three after changes — this is the standard loop.
- Run locally: `go run ./cmd serve` (flags: `--storage=memory --vault=memory` to
avoid external deps; see `cmd/main.go` / `pkg/config`).
- Postgres and Vault-backed tests use testcontainers and **skip when Docker is
unavailable** (`internal/testutil`). `go test ./...` passes without Docker but
only exercises the memory backends; run with Docker for full coverage.
- Editor/LSP diagnostics can lag after cross-file or cross-package edits —
`go build` / `go vet` are authoritative, prefer them over stale squiggles.

## Layout

- `cmd/main.go` — cobra entrypoint (`serve`).
- `pkg/fx` — uber-fx wiring. `AppModule` picks the storage (`memory`/`postgres`)
and vault (`memory`/`hashicorp`) backend from config; `ProvideConfigs` splits
`config.Config` into injectable sub-configs; handlers/services are registered
here. DI is **by type** — a constructor just declares the deps it needs and the
provider must exist in the graph.
- `pkg/config` — viper config: file + `HILT_` env prefix (`.`→`_`) + cobra flags.
- `pkg/api` — Tenant REST handlers + the partner-key auth middleware.
- `pkg/rpc` — UCAN S3 command handlers; `pkg/rpc/service/auth` is the shared
`Authorizer` service.
- `pkg/sigv4` — stdlib-only SigV4 / SigV4a verification, key derivation
(`DeriveKey`), and local verification (`VerifyWithKey`).
- `pkg/s3perm` — S3-permission → Forge-command mapping (shared by `api` and `rpc`).
- `pkg/store/{tenant,accesskey,bucket,delegation,provider}` — each an interface
with `memory` and `postgres` backends.
- `pkg/vault` (`memory`, `hashicorp`) — private-key storage; `paths.go` has the
key path helpers (`TenantKeyPath`, `AccessKeyPath`).
- `pkg/client` — clients for external services (the Sprue `UploadClient`).
- `pkg/migrations` — goose SQL migrations run on startup (unless skipped).
- `internal/testutil` — test-only helpers (random DIDs/issuers, testcontainers).

## Conventions

- **New packages go under `pkg/`**, not `internal/` (only test helpers live in
`internal/`).
- **Stores**: an interface in `pkg/store/<entity>` with `memory` + `postgres`
implementations kept in lockstep and exercised by one backend-parametrized test
suite (`<entity>_test.go`). Add a method to all three (interface + both backends)
and cover it in that suite.
- **RPC handlers** follow one shape: a `New<Cmd>Handler(logger, deps…) server.Route`
constructor that returns the libforge bound command's `.Route(...)`, whose closure
extracts `req.Invocation().Issuer()` / `req.Task().Arguments()` and delegates to an
**exported, testable** function (`ctx, logger, deps…, issuer, args`). That function
returns `(*OK, []ucan.Delegation, error)`; the closure calls `res.SetFailure(err)`
or `res.SetSuccess(ok)`, and attaches any delegation blocks via
`res.SetMetadata(container.New(container.WithDelegations(blocks...)))` (the result's
delegation map carries only CIDs — the blocks ride back in the container).
- **Use libforge bound commands** (`.Command`, `.Route`, `.Invoke`, `.Unpack`) — do
not hand-write command strings with `command.MustParse`.
- **Authorization**: signature-bearing S3 commands authenticate via the
`auth.Authorizer` service (SigV4/SigV4a verify + time bounds + issuer == tenant's
provider + region served by that provider). Command-specific S3-permission checks
stay in each handler. `/s3/bucket/info` is an unauthenticated lookup (no signed
request).
- **Identities & keys**: tenants are secp256k1 → did:plc; access keys and buckets
are ed25519 → did:key. Build issuers with `multikey.NewIssuer(did, signer)`. Bucket
keys are **ephemeral** — used once to sign the bucket→tenant root delegation, then
discarded (never vaulted). Delegations are issued with `ucan/delegation.Delegate`;
proof chains come from `delegation.Store.ProofChain`.
- **Terminology**: a UCAN delegation is *issued* (or *re-delegated*), never *minted*.
Use "issue" in prose, comments, and commit messages.
- **Config**: surface new settings as a sub-config field, wire it through
`pkg/fx/config.go` `ProvideConfigs`, and add a cobra flag in `cmd/main.go`.
- **fx graph validation**: when a config change alters which modules `AppModule`
(`pkg/fx/app.go`) wires — a new backend or any config-driven module selection —
add a case to `pkg/fx/app_test.go` covering each permutation, asserting
`fx.ValidateApp(appfx.AppModule(cfg), fx.NopLogger)` returns no error (and errors
for an invalid/unknown selection). This proves every module combination yields a
graph with all dependencies satisfied, without starting the app.

## Security

Key material is sensitive. **Never log or echo private keys, secrets, or seed
material — log DIDs only.** The vault stores raw key bytes; only DIDs and CIDs
should appear in logs, errors, or test output. Secret-bearing CLI flags (vault
token, AppRole secret, partner key, etc.) warn to prefer `HILT_*` env vars or the
config file over process args; keep that guidance. Use placeholders (never real
values) for keys/tokens in examples and generated config.
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
92 changes: 92 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,95 @@
# 🗡️ Hilt

Service for managing tenants of Ingot and their secret keys. Hilt implements the Tenant API, provides a UCAN API for retrieving proof chains for invocations into the Forge network and speaks to the Forge upload service.

## Configuration

`hilt serve` is configured from three sources, highest precedence first:
**command-line flag → `HILT_*` environment variable → config file → built-in
default**. The config file is YAML, selected with `-c/--config` (default: a
`config.yaml` in the working directory, then `/etc/hilt/config.yaml`). Every key
has an env var: `HILT_` + the key uppercased with `.` replaced by `_` (e.g.
`storage.postgres.dsn` → `HILT_STORAGE_POSTGRES_DSN`). Config-file keys are the
dotted paths below (nested YAML), e.g. `storage: { postgres: { dsn: ... } }`.

Secrets (partner key, Vault token, AppRole secret ID) should be provided via env
var or config file, **not** flags, to avoid exposing them in process args.

### Identity (UCAN RPC service identity)

| Key | Flag | Env var | Default |
| --- | --- | --- | --- |
| `identity.key_file` | `--identity-key-file` | `HILT_IDENTITY_KEY_FILE` | _(ephemeral key)_ |
| `identity.service_id` | `--identity-service-id` | `HILT_IDENTITY_SERVICE_ID` | _(key's did:key)_ |

`key_file` is a PEM-encoded Ed25519 key; when unset an ephemeral key is generated
(its DID changes each restart). `service_id` optionally wraps the key with a
`did:web` (e.g. `did:web:hilt.example.com`).

### Server

| Key | Flag | Env var | Default |
| --- | --- | --- | --- |
| `server.host` | `--host` | `HILT_SERVER_HOST` | `127.0.0.1` |
| `server.port` | `--port` | `HILT_SERVER_PORT` | `8080` |

### Logging

| Key | Flag | Env var | Default |
| --- | --- | --- | --- |
| `log.level` | _(none)_ | `HILT_LOG_LEVEL` | `info` |

### Storage

| Key | Flag | Env var | Default |
| --- | --- | --- | --- |
| `storage.type` | `--storage` | `HILT_STORAGE_TYPE` | `postgres` |
| `storage.postgres.dsn` | `--postgres-dsn` | `HILT_STORAGE_POSTGRES_DSN` | `postgres://hilt:hilt@localhost:5432/hilt?sslmode=disable` |
| `storage.postgres.max_conns` | _(none)_ | `HILT_STORAGE_POSTGRES_MAX_CONNS` | `10` |
| `storage.postgres.min_conns` | _(none)_ | `HILT_STORAGE_POSTGRES_MIN_CONNS` | `0` |
| `storage.postgres.skip_migrations` | `--skip-migrations` | `HILT_STORAGE_POSTGRES_SKIP_MIGRATIONS` | `false` |

`storage.type` is `postgres` or `memory`. Postgres keys apply when
`type=postgres`; migrations run on startup unless `skip_migrations` is set.

### Vault (private-key storage)

| Key | Flag | Env var | Default |
| --- | --- | --- | --- |
| `vault.type` | `--vault` | `HILT_VAULT_TYPE` | `hashicorp` |
| `vault.hashicorp.address` | `--hashicorp-address` | `HILT_VAULT_HASHICORP_ADDRESS` | `http://127.0.0.1:8200` |
| `vault.hashicorp.mount` | `--hashicorp-mount` | `HILT_VAULT_HASHICORP_MOUNT` | `secret` |
| `vault.hashicorp.auth_method` | `--hashicorp-auth-method` | `HILT_VAULT_HASHICORP_AUTH_METHOD` | `approle` |
| `vault.hashicorp.token` | `--hashicorp-token` | `HILT_VAULT_HASHICORP_TOKEN` | _(none)_ — **secret** |
| `vault.hashicorp.approle.role_id` | `--hashicorp-approle-role-id` | `HILT_VAULT_HASHICORP_APPROLE_ROLE_ID` | _(none)_ |
| `vault.hashicorp.approle.secret_id` | `--hashicorp-approle-secret-id` | `HILT_VAULT_HASHICORP_APPROLE_SECRET_ID` | _(none)_ — **secret** |
| `vault.hashicorp.approle.mount` | `--hashicorp-approle-mount` | `HILT_VAULT_HASHICORP_APPROLE_MOUNT` | `approle` |

`vault.type` is `hashicorp` or `memory`. HashiCorp keys apply when
`type=hashicorp`; `auth_method` is `approle` or `token` (use `token` with
`vault.hashicorp.token`, or `approle` with the role/secret IDs).

### PLC directory

| Key | Flag | Env var | Default |
| --- | --- | --- | --- |
| `plc.directory` | `--plc-directory` | `HILT_PLC_DIRECTORY` | `https://plc.directory` |

### Tenant API auth

| Key | Flag | Env var | Default |
| --- | --- | --- | --- |
| `auth.partner_key` | `--partner-key` | `HILT_AUTH_PARTNER_KEY` | _(none)_ — **secret** |

Pre-shared bearer token required on Tenant API requests.

### Upload service (Sprue)

| Key | Flag | Env var | Default |
| --- | --- | --- | --- |
| `upload.service_id` | `--upload-service-id` | `HILT_UPLOAD_SERVICE_ID` | `did:web:upload.forgery.network` |
| `upload.service_url` | `--upload-service-url` | `HILT_UPLOAD_SERVICE_URL` | `https://upload.forgery.network` |
| `upload.product_id` | `--upload-product-id` | `HILT_UPLOAD_PRODUCT_ID` | `did:web:hilt.forgery.network` |

The Sprue service DID + HTTP endpoint Hilt calls to provision bucket space, and
the product/plan DID tenants are registered under.
6 changes: 6 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ func main() {
// auth config
serveCmd.Flags().String("partner-key", "", "partner bearer key required on Tenant API requests (prefer HILT_AUTH_PARTNER_KEY env var or config file to avoid exposing via process args)")

// upload service config
serveCmd.Flags().String("upload-service-id", "did:web:upload.forgery.network", "Upload service DID")
serveCmd.Flags().String("upload-service-url", "https://upload.forgery.network", "Upload service HTTP endpoint")
serveCmd.Flags().String("upload-product-id", "did:web:hilt.forgery.network", "Upload service product/plan DID that tenants are registered under")
serveCmd.Flags().String("upload-proofs", "", "Upload service proofs: an encoded UCAN container or a path to a file containing one")

rootCmd.AddCommand(serveCmd)

rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file path (default: looks for config.yaml in current dir)")
Expand Down
27 changes: 0 additions & 27 deletions internal/testutil/alias.go

This file was deleted.

35 changes: 14 additions & 21 deletions pkg/api/access_keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"net/http"

"github.com/fil-forge/hilt/pkg/s3perm"
"github.com/fil-forge/hilt/pkg/store"
"github.com/fil-forge/hilt/pkg/store/accesskey"
"github.com/fil-forge/hilt/pkg/store/bucket"
Expand All @@ -24,18 +25,6 @@ import (

const maxAccessKeyNameLength = 100

// vaultTenantKeyPath is the vault key under which a tenant's private key is
// stored.
func vaultTenantKeyPath(tenantDID did.DID) string {
return "/tenant/" + tenantDID.String()
}

// vaultAccessKeyPath is the vault key under which an access key's private key is
// stored. It MUST match the path used by the tenant delete cascade.
func vaultAccessKeyPath(tenantDID, accessKeyDID did.DID) string {
return vaultTenantKeyPath(tenantDID) + "/access/" + accessKeyDID.String()
}

// NewCreateAccessKeyHandler handles POST /tenants/{tenantId}/access-keys —
// create an S3 access-key pair (returns the secret once only) and issue the
// tenant→access-key UCAN delegations for the requested permissions.
Expand All @@ -62,7 +51,7 @@ func NewCreateAccessKeyHandler(
return echo.NewHTTPError(http.StatusUnprocessableEntity, "at least one permission is required")
}
for _, p := range req.Permissions {
if !validS3Permission(p) {
if !s3perm.Valid(p) {
return echo.NewHTTPError(http.StatusUnprocessableEntity, "unknown permission: "+p)
}
}
Expand All @@ -78,7 +67,7 @@ func NewCreateAccessKeyHandler(

// Load the tenant signer up front: it is required to issue delegations and
// its absence is unrecoverable, so fail before creating any state.
tenantKeyBytes, err := secrets.Read(ctx, vaultTenantKeyPath(tenantRec.ID))
tenantKeyBytes, err := secrets.Read(ctx, vault.TenantKeyPath(tenantRec.ID))
if err != nil {
log.Error("reading tenant key", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, "internal error")
Expand Down Expand Up @@ -131,22 +120,26 @@ func NewCreateAccessKeyHandler(
}
log = log.With(zap.Stringer("access_key", accessKeyDID))

vaultPath := vaultAccessKeyPath(tenantRec.ID, accessKeyDID)
vaultPath := vault.AccessKeyPath(tenantRec.ID, accessKeyDID)
if err := secrets.Write(ctx, vaultPath, signer.Bytes()); err != nil {
log.Error("storing access key", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, "internal error")
}

// Best-effort rollback of the (idempotent) state created below, so a
// partial failure leaves nothing behind and is retryable.
// partial failure leaves nothing behind and is retryable. Cleanup runs on a
// context detached from the request (values retained, cancellation/deadline
// dropped) so a client disconnect — which cancels ctx — cannot abort the
// rollback partway and leave orphaned state.
rollback := func() {
if err := delegations.DeleteByAudience(ctx, accessKeyDID); err != nil {
cleanupCtx := context.WithoutCancel(ctx)
if err := delegations.DeleteByAudience(cleanupCtx, accessKeyDID); err != nil {
log.Warn("rollback: deleting delegations", zap.Error(err))
}
if err := accessKeys.Delete(ctx, accessKeyDID); err != nil {
if err := accessKeys.Delete(cleanupCtx, accessKeyDID); err != nil {
log.Warn("rollback: deleting access key", zap.Error(err))
}
if err := secrets.Delete(ctx, vaultPath); err != nil {
if err := secrets.Delete(cleanupCtx, vaultPath); err != nil {
log.Warn("rollback: deleting access key from vault", zap.Error(err))
}
}
Expand All @@ -169,7 +162,7 @@ func NewCreateAccessKeyHandler(
}
var dels []ucan.Delegation
for _, sub := range subjects {
for _, cmd := range commandsForPermissions(req.Permissions) {
for _, cmd := range s3perm.CommandsFor(req.Permissions...) {
d, err := delegation.Delegate(issuer, accessKeyDID, sub, cmd, opts...)
if err != nil {
rollback()
Expand Down Expand Up @@ -340,7 +333,7 @@ func NewDeleteAccessKeyHandler(
log.Error("deleting access key delegations", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, "internal error")
}
if err := secrets.Delete(ctx, vaultAccessKeyPath(tenantRec.ID, accessKeyDID)); err != nil {
if err := secrets.Delete(ctx, vault.AccessKeyPath(tenantRec.ID, accessKeyDID)); err != nil {
log.Warn("removing access key from vault", zap.Error(err))
}
if err := accessKeys.Delete(ctx, accessKeyDID); err != nil {
Expand Down
4 changes: 2 additions & 2 deletions pkg/api/access_keys_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"testing"
"time"

"github.com/fil-forge/hilt/internal/testutil"
"github.com/fil-forge/hilt/pkg/api"
"github.com/fil-forge/hilt/pkg/store"
accesskeymemory "github.com/fil-forge/hilt/pkg/store/accesskey/memory"
Expand All @@ -18,6 +17,7 @@ import (
tenantmemory "github.com/fil-forge/hilt/pkg/store/tenant/memory"
"github.com/fil-forge/hilt/pkg/vault"
vaultmemory "github.com/fil-forge/hilt/pkg/vault/memory"
"github.com/fil-forge/libforge/testutil"
"github.com/fil-forge/ucantone/did"
"github.com/fil-forge/ucantone/did/plc"
"github.com/fil-forge/ucantone/multikey/secp256k1"
Expand Down Expand Up @@ -119,7 +119,7 @@ func TestCreateAccessKeyHandler(t *testing.T) {
require.Equal(t, []did.DID{deps.bucketID}, storedRec.Buckets)

// Private key in the vault.
_, err = deps.vault.Read(ctx, "/tenant/"+deps.tenantID.String()+"/access/"+akDID.String())
_, err = deps.vault.Read(ctx, "/tenant/"+deps.tenantID.String()+"/access-key/"+akDID.String())
require.NoError(t, err)

// 4 delegations: /content/retrieve + /blob/add + /index/add + /upload/add,
Expand Down
Loading
Loading