diff --git a/README.md b/README.md index 3c59417..9d141d3 100644 --- a/README.md +++ b/README.md @@ -80,8 +80,10 @@ deployment. ### API Keys And First-Party JWTs Active when `FMSG_API_TOKEN_ED25519_PRIVATE_KEY` is set. Programmatic clients -authenticate with opaque API keys bound to sub-account addresses. The server -stores only API-key hashes and exchanges valid keys for short-lived Ed25519 JWTs. +authenticate with opaque API keys bound to API-access grants. A grant may be a +derived sub-account such as `@alice_bot@example.com`, or an explicit delegated +identity such as `@sales@example.com`. The server stores only API-key hashes and +exchanges valid keys for short-lived Ed25519 JWTs. API keys are sent only to `POST /fmsg/token`: @@ -89,24 +91,26 @@ API keys are sent only to `POST /fmsg/token`: Authorization: Bearer fmsgk__ ``` -The returned JWT contains `sub` (the sub-account address), `owner`, `api_key_id`, +The returned JWT contains `sub` (the granted address), `owner`, `api_key_id`, `iss`, `aud`, `iat`, and `exp`. Protected routes re-check the backing key row on -each request, so deleting a sub-account or expiring its key invalidates existing +each request, so deleting a grant or expiring its key invalidates existing tokens before their normal expiry. An RS256-authenticated owner can perform normal message routes as one of their -sub-accounts without changing request bodies: +granted identities without changing request bodies: ```http X-FMSG-Act-As: @user_bot@example.com ``` -The requested sub-account must be owned by the authenticated user and must exist +The requested address must be granted to the authenticated user and must exist in fmsgid. -Apply [api_keys.sql](api_keys.sql) before enabling API-key auth. +Apply [api_keys.sql](api_keys.sql) before enabling API-key auth. Existing +deployments that already applied the earlier API-key table should apply +[api_keys_delegation.sql](api_keys_delegation.sql). -To set a custom per-owner sub-account limit, insert an owner config row: +To set a custom per-owner grant limit, insert an owner config row: ```sql INSERT INTO fmsg_api_sub_account (owner_addr, agent, max_sub_accounts) @@ -117,7 +121,9 @@ DO UPDATE SET max_sub_accounts = EXCLUDED.max_sub_accounts; Operators can bootstrap or rotate keys without RS256 by using the built-in CLI command. It uses the standard `PG*` connection environment variables and prints -the plaintext API key once: +the plaintext API key once. + +Derived sub-account: ```bash go run ./cmd/fmsg-webapi api-key create \ @@ -132,6 +138,22 @@ go run ./cmd/fmsg-webapi api-key rotate \ -expires 2027-03-31T00:00:00Z ``` +Delegated identity: + +```bash +go run ./cmd/fmsg-webapi api-key create-delegation \ + -owner @mark@fmsg.io \ + -agent sales \ + -addr @sales@fmsg.io \ + -cidr 203.0.113.0/24 \ + -expires 2026-12-31T00:00:00Z + +go run ./cmd/fmsg-webapi api-key rotate-delegation \ + -owner @mark@fmsg.io \ + -agent sales \ + -expires 2027-03-31T00:00:00Z +``` + ## Building Requires **Go 1.25** or newer. @@ -215,10 +237,10 @@ the application. | `GET` | `/fmsg/sent` | List authored messages (sent + drafts) | | `GET` | `/fmsg/ws` | WebSocket for pushed event notifications | | `POST` | `/fmsg/token` | Exchange an API key for a JWT | -| `GET` | `/fmsg/sub-accounts` | List owned sub-accounts | -| `POST` | `/fmsg/sub-accounts` | Create a sub-account API key | -| `POST` | `/fmsg/sub-accounts/:agent/rotate-key` | Rotate a sub-account API key | -| `DELETE` | `/fmsg/sub-accounts/:agent` | Delete a sub-account | +| `GET` | `/fmsg/sub-accounts` | List owned API-access grants | +| `POST` | `/fmsg/sub-accounts` | Create a derived sub-account API key | +| `POST` | `/fmsg/sub-accounts/:agent/rotate-key` | Rotate a grant API key | +| `DELETE` | `/fmsg/sub-accounts/:agent` | Delete a grant | | `POST` | `/fmsg` | Create a draft message | | `GET` | `/fmsg/:id` | Retrieve a message | | `PUT` | `/fmsg/:id` | Update a draft message | @@ -246,7 +268,7 @@ Exchanges an opaque API key for a short-lived JWT. **Authentication:** `Authorization: Bearer fmsgk__`. The key must be unexpired, match the stored hash, be used from an allowed CIDR, -and belong to a sub-account that exists in fmsgid. +and belong to a granted address that exists in fmsgid. **Response:** @@ -261,7 +283,10 @@ and belong to a sub-account that exists in fmsgid. ### GET `/fmsg/sub-accounts` -Lists sub-accounts owned by the RS256-authenticated user. +Lists API-access grants owned by the RS256-authenticated user. Grants with +`grant_type: "derived_sub_account"` use the `@user_agent@domain` convention. +Grants with `grant_type: "delegated_identity"` are explicit operator-created +delegations to arbitrary fmsg addresses. **Response:** @@ -272,9 +297,19 @@ Lists sub-accounts owned by the RS256-authenticated user. { "agent": "bot", "addr": "@alice_bot@example.com", + "grant_type": "derived_sub_account", "key_id": "abc", "allowed_cidrs": ["203.0.113.0/24"], "key_expires_at": "2026-12-31T00:00:00Z" + }, + { + "agent": "sales", + "addr": "@sales@example.com", + "grant_type": "delegated_identity", + "display_name": "Sales mailbox", + "key_id": "def", + "allowed_cidrs": ["203.0.113.0/24"], + "key_expires_at": "2026-12-31T00:00:00Z" } ] } @@ -282,8 +317,8 @@ Lists sub-accounts owned by the RS256-authenticated user. ### POST `/fmsg/sub-accounts` -Creates a sub-account and returns its plaintext API key once. Requires RS256 -owner authentication. +Creates a derived sub-account and returns its plaintext API key once. Requires +RS256 owner authentication. ```json { @@ -296,15 +331,21 @@ owner authentication. The derived address is `@user_bot@domain`. `agent` may contain letters, digits, dots, and hyphens, but not underscores. +Delegated identities such as `@sales@example.com` are not created by this +self-service route. They are operator-created with `api-key create-delegation` +after the operator has confirmed the owner is allowed to manage the delegated +address. + ### POST `/fmsg/sub-accounts/:agent/rotate-key` -Rotates a sub-account API key and returns the new plaintext key once. Requires -`key_expires_at`; `allowed_cidrs` may be supplied to replace the existing ranges. +Rotates any grant API key owned by the RS256-authenticated user and returns the +new plaintext key once. Requires `key_expires_at`; `allowed_cidrs` may be +supplied to replace the existing ranges. ### DELETE `/fmsg/sub-accounts/:agent` -Deletes a sub-account row and revokes future token exchange. Existing JWTs for -that key are rejected on their next protected-route request. +Deletes a grant row and revokes future token exchange. Existing JWTs for that +key are rejected on their next protected-route request. ### GET `/fmsg/ws` diff --git a/api_keys.sql b/api_keys.sql index 0e1a06d..6915de8 100644 --- a/api_keys.sql +++ b/api_keys.sql @@ -2,6 +2,8 @@ CREATE TABLE IF NOT EXISTS fmsg_api_sub_account ( owner_addr varchar(255) NOT NULL, agent varchar(64) NOT NULL, sub_addr varchar(255), + grant_type text NOT NULL DEFAULT 'derived_sub_account', + display_name text, key_id varchar(64), key_hash bytea, allowed_cidrs cidr[], @@ -10,11 +12,11 @@ CREATE TABLE IF NOT EXISTS fmsg_api_sub_account ( created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (owner_addr, agent), - UNIQUE (sub_addr), UNIQUE (key_id), CHECK (max_sub_accounts > 0), + CHECK (grant_type IN ('derived_sub_account', 'delegated_identity')), CHECK ( - (agent = '' AND sub_addr IS NULL AND key_id IS NULL AND key_hash IS NULL AND allowed_cidrs IS NULL AND key_expires_at IS NULL) + (agent = '' AND sub_addr IS NULL AND display_name IS NULL AND key_id IS NULL AND key_hash IS NULL AND allowed_cidrs IS NULL AND key_expires_at IS NULL) OR (agent <> '' AND sub_addr IS NOT NULL AND key_id IS NOT NULL AND key_hash IS NOT NULL AND allowed_cidrs IS NOT NULL AND cardinality(allowed_cidrs) > 0 AND key_expires_at IS NOT NULL) ), @@ -26,3 +28,7 @@ CREATE INDEX IF NOT EXISTS fmsg_api_sub_account_owner_idx CREATE INDEX IF NOT EXISTS fmsg_api_sub_account_sub_idx ON fmsg_api_sub_account ((lower(sub_addr))); + +CREATE UNIQUE INDEX IF NOT EXISTS fmsg_api_sub_account_owner_sub_unique + ON fmsg_api_sub_account ((lower(owner_addr)), (lower(sub_addr))) + WHERE agent <> ''; diff --git a/api_keys_delegation.sql b/api_keys_delegation.sql new file mode 100644 index 0000000..93f2730 --- /dev/null +++ b/api_keys_delegation.sql @@ -0,0 +1,25 @@ +ALTER TABLE fmsg_api_sub_account + ADD COLUMN IF NOT EXISTS grant_type text NOT NULL DEFAULT 'derived_sub_account'; + +ALTER TABLE fmsg_api_sub_account + ADD COLUMN IF NOT EXISTS display_name text; + +ALTER TABLE fmsg_api_sub_account + DROP CONSTRAINT IF EXISTS fmsg_api_sub_account_sub_addr_key; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fmsg_api_sub_account_grant_type_check' + ) THEN + ALTER TABLE fmsg_api_sub_account + ADD CONSTRAINT fmsg_api_sub_account_grant_type_check + CHECK (grant_type IN ('derived_sub_account', 'delegated_identity')); + END IF; +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS fmsg_api_sub_account_owner_sub_unique + ON fmsg_api_sub_account ((lower(owner_addr)), (lower(sub_addr))) + WHERE agent <> ''; diff --git a/cmd/fmsg-webapi/apikey_cli.go b/cmd/fmsg-webapi/apikey_cli.go index 803dfce..096f1b7 100644 --- a/cmd/fmsg-webapi/apikey_cli.go +++ b/cmd/fmsg-webapi/apikey_cli.go @@ -15,13 +15,17 @@ import ( func runAPIKeyCLI(ctx context.Context, args []string) error { if len(args) == 0 { - return fmt.Errorf("usage: api-key create|rotate -owner @user@domain -agent name -cidr 203.0.113.0/24 -expires 2026-12-31T00:00:00Z") + return fmt.Errorf("usage: api-key create|rotate|create-delegation|rotate-delegation -owner @user@domain -agent name -cidr 203.0.113.0/24 -expires 2026-12-31T00:00:00Z") } switch args[0] { case "create": return runAPIKeyCreate(ctx, args[1:]) case "rotate": return runAPIKeyRotate(ctx, args[1:]) + case "create-delegation": + return runAPIKeyCreateDelegation(ctx, args[1:]) + case "rotate-delegation": + return runAPIKeyRotateDelegation(ctx, args[1:]) default: return fmt.Errorf("unknown api-key command %q", args[0]) } @@ -93,17 +97,96 @@ func runAPIKeyRotate(ctx context.Context, args []string) error { return nil } +func runAPIKeyCreateDelegation(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("api-key create-delegation", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + owner := fs.String("owner", "", "owner fmsg address") + agent := fs.String("agent", "", "delegation label") + addr := fs.String("addr", "", "delegated fmsg address") + displayName := fs.String("display-name", "", "optional display name") + cidrs := fs.String("cidr", "", "comma-separated allowed CIDR ranges") + expiresRaw := fs.String("expires", "", "API key expiry as RFC3339 timestamp") + if err := fs.Parse(args); err != nil { + return err + } + + allowed, expires, key, hash, err := prepareCLIGrantInputs(*owner, *agent, *cidrs, *expiresRaw) + if err != nil { + return err + } + if len(allowed) == 0 { + return fmt.Errorf("cidr is required for create-delegation") + } + if !middleware.IsValidAddr(*addr) { + return fmt.Errorf("addr must be an fmsg address") + } + database, err := db.New(ctx, "") + if err != nil { + return err + } + defer database.Close() + + store := apiauth.NewStore(database) + if err := store.CreateDelegated(ctx, *owner, *agent, *addr, *displayName, key.ID, hash, allowed, expires); err != nil { + return err + } + printCLIKey(*owner, *agent, *addr, key) + return nil +} + +func runAPIKeyRotateDelegation(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("api-key rotate-delegation", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + owner := fs.String("owner", "", "owner fmsg address") + agent := fs.String("agent", "", "delegation label") + cidrs := fs.String("cidr", "", "comma-separated allowed CIDR ranges; omit to keep existing") + expiresRaw := fs.String("expires", "", "API key expiry as RFC3339 timestamp") + if err := fs.Parse(args); err != nil { + return err + } + + allowed, expires, key, hash, err := prepareCLIGrantInputs(*owner, *agent, *cidrs, *expiresRaw) + if err != nil { + return err + } + database, err := db.New(ctx, "") + if err != nil { + return err + } + defer database.Close() + + store := apiauth.NewStore(database) + replaceCIDRs := strings.TrimSpace(*cidrs) != "" + subAddr, err := store.RotateKey(ctx, *owner, *agent, key.ID, hash, expires, allowed, replaceCIDRs) + if err != nil { + return err + } + printCLIKey(*owner, *agent, subAddr, key) + return nil +} + func prepareCLIKeyInputs(owner, agent, cidrsRaw, expiresRaw string) (string, []string, time.Time, apiauth.APIKey, []byte, error) { - if !middleware.IsValidAddr(owner) { - return "", nil, time.Time{}, apiauth.APIKey{}, nil, fmt.Errorf("owner must be an fmsg address") + allowed, expires, key, hash, err := prepareCLIGrantInputs(owner, agent, cidrsRaw, expiresRaw) + if err != nil { + return "", nil, time.Time{}, apiauth.APIKey{}, nil, err } subAddr, err := apiauth.DeriveSubAccountAddr(owner, agent) if err != nil { return "", nil, time.Time{}, apiauth.APIKey{}, nil, err } + return subAddr, allowed, expires, key, hash, nil +} + +func prepareCLIGrantInputs(owner, agent, cidrsRaw, expiresRaw string) ([]string, time.Time, apiauth.APIKey, []byte, error) { + if !middleware.IsValidAddr(owner) { + return nil, time.Time{}, apiauth.APIKey{}, nil, fmt.Errorf("owner must be an fmsg address") + } + if err := apiauth.ValidateAgent(agent); err != nil { + return nil, time.Time{}, apiauth.APIKey{}, nil, err + } expires, err := time.Parse(time.RFC3339, expiresRaw) if err != nil || !expires.After(time.Now()) { - return "", nil, time.Time{}, apiauth.APIKey{}, nil, fmt.Errorf("expires must be a future RFC3339 timestamp") + return nil, time.Time{}, apiauth.APIKey{}, nil, fmt.Errorf("expires must be a future RFC3339 timestamp") } var allowed []string if strings.TrimSpace(cidrsRaw) != "" { @@ -113,14 +196,14 @@ func prepareCLIKeyInputs(owner, agent, cidrsRaw, expiresRaw string) (string, []s } if len(allowed) > 0 { if err := apiauth.ValidateCIDRs(allowed); err != nil { - return "", nil, time.Time{}, apiauth.APIKey{}, nil, fmt.Errorf("invalid CIDR: %w", err) + return nil, time.Time{}, apiauth.APIKey{}, nil, fmt.Errorf("invalid CIDR: %w", err) } } key, err := apiauth.GenerateAPIKey() if err != nil { - return "", nil, time.Time{}, apiauth.APIKey{}, nil, err + return nil, time.Time{}, apiauth.APIKey{}, nil, err } - return subAddr, allowed, expires, key, apiauth.HashAPIKey(key.Value), nil + return allowed, expires, key, apiauth.HashAPIKey(key.Value), nil } func printCLIKey(owner, agent, subAddr string, key apiauth.APIKey) { diff --git a/cmd/fmsg-webapi/apikey_cli_test.go b/cmd/fmsg-webapi/apikey_cli_test.go new file mode 100644 index 0000000..46d0ce4 --- /dev/null +++ b/cmd/fmsg-webapi/apikey_cli_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +func TestPrepareCLIGrantInputsAllowsArbitraryDelegatedAddressFlow(t *testing.T) { + expires := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + allowed, gotExpires, key, hash, err := prepareCLIGrantInputs("@mark@fmsg.io", "sales", "203.0.113.0/24", expires) + if err != nil { + t.Fatal(err) + } + if len(allowed) != 1 || allowed[0] != "203.0.113.0/24" { + t.Fatalf("allowed = %#v", allowed) + } + if !gotExpires.After(time.Now()) { + t.Fatalf("expiry should be in the future: %s", gotExpires) + } + if key.Value == "" || len(hash) == 0 { + t.Fatalf("key/hash not generated") + } +} + +func TestPrepareCLIKeyInputsStillDerivesSubAccountAddress(t *testing.T) { + expires := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + subAddr, _, _, _, _, err := prepareCLIKeyInputs("@mark@fmsg.io", "bot", "203.0.113.0/24", expires) + if err != nil { + t.Fatal(err) + } + if subAddr != "@mark_bot@fmsg.io" { + t.Fatalf("subAddr = %q", subAddr) + } +} + +func TestPrepareCLIGrantInputsRejectsInvalidAgent(t *testing.T) { + expires := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + _, _, _, _, err := prepareCLIGrantInputs("@mark@fmsg.io", "sales_team", "203.0.113.0/24", expires) + if err == nil || !strings.Contains(err.Error(), "invalid agent") { + t.Fatalf("err = %v", err) + } +} diff --git a/internal/apiauth/store.go b/internal/apiauth/store.go index bdd082f..1308341 100644 --- a/internal/apiauth/store.go +++ b/internal/apiauth/store.go @@ -15,10 +15,15 @@ import ( "github.com/markmnl/fmsg-webapi/internal/db" ) -const DefaultMaxSubAccounts = 5 +const ( + DefaultMaxSubAccounts = 5 + + GrantTypeDerivedSubAccount = "derived_sub_account" + GrantTypeDelegatedIdentity = "delegated_identity" +) var ( - ErrNotFound = errors.New("sub-account not found") + ErrNotFound = errors.New("grant not found") ErrAlreadyExists = errors.New("sub-account already exists") ErrLimitExceeded = errors.New("sub-account limit exceeded") ErrCIDRDenied = errors.New("source IP not allowed") @@ -35,6 +40,8 @@ type SubAccount struct { OwnerAddr string Agent string Addr string + GrantType string + DisplayName string KeyID string AllowedCIDRs []string KeyExpiresAt time.Time @@ -57,7 +64,7 @@ func (s *Store) List(ctx context.Context, ownerAddr string) (int, []SubAccount, return 0, nil, err } rows, err := s.DB.Pool.Query(ctx, - `SELECT owner_addr, agent, sub_addr, key_id, + `SELECT owner_addr, agent, sub_addr, grant_type, COALESCE(display_name, ''), key_id, ARRAY(SELECT cidr_value::text FROM unnest(allowed_cidrs) AS x(cidr_value)), key_expires_at, max_sub_accounts FROM fmsg_api_sub_account @@ -71,7 +78,7 @@ func (s *Store) List(ctx context.Context, ownerAddr string) (int, []SubAccount, var out []SubAccount for rows.Next() { var a SubAccount - if err := rows.Scan(&a.OwnerAddr, &a.Agent, &a.Addr, &a.KeyID, &a.AllowedCIDRs, &a.KeyExpiresAt, &a.MaxSubAccounts); err != nil { + if err := rows.Scan(&a.OwnerAddr, &a.Agent, &a.Addr, &a.GrantType, &a.DisplayName, &a.KeyID, &a.AllowedCIDRs, &a.KeyExpiresAt, &a.MaxSubAccounts); err != nil { return 0, nil, err } out = append(out, a) @@ -97,7 +104,34 @@ func (s *Store) MaxSubAccounts(ctx context.Context, ownerAddr string) (int, erro return max, nil } +func (s *Store) Get(ctx context.Context, ownerAddr, agent string) (SubAccount, error) { + var a SubAccount + err := s.DB.Pool.QueryRow(ctx, + `SELECT owner_addr, agent, sub_addr, grant_type, COALESCE(display_name, ''), key_id, + ARRAY(SELECT cidr_value::text FROM unnest(allowed_cidrs) AS x(cidr_value)), + key_expires_at, max_sub_accounts + FROM fmsg_api_sub_account + WHERE lower(owner_addr) = lower($1) AND agent = $2 AND agent <> ''`, + ownerAddr, agent). + Scan(&a.OwnerAddr, &a.Agent, &a.Addr, &a.GrantType, &a.DisplayName, &a.KeyID, &a.AllowedCIDRs, &a.KeyExpiresAt, &a.MaxSubAccounts) + if errors.Is(err, pgx.ErrNoRows) { + return SubAccount{}, ErrNotFound + } + if err != nil { + return SubAccount{}, err + } + return a, nil +} + func (s *Store) Create(ctx context.Context, ownerAddr, agent, subAddr, keyID string, keyHash []byte, allowedCIDRs []string, keyExpiresAt time.Time) error { + return s.createGrant(ctx, ownerAddr, agent, subAddr, GrantTypeDerivedSubAccount, "", keyID, keyHash, allowedCIDRs, keyExpiresAt) +} + +func (s *Store) CreateDelegated(ctx context.Context, ownerAddr, agent, delegatedAddr, displayName, keyID string, keyHash []byte, allowedCIDRs []string, keyExpiresAt time.Time) error { + return s.createGrant(ctx, ownerAddr, agent, delegatedAddr, GrantTypeDelegatedIdentity, displayName, keyID, keyHash, allowedCIDRs, keyExpiresAt) +} + +func (s *Store) createGrant(ctx context.Context, ownerAddr, agent, subAddr, grantType, displayName, keyID string, keyHash []byte, allowedCIDRs []string, keyExpiresAt time.Time) error { tx, err := s.DB.Pool.Begin(ctx) if err != nil { return err @@ -121,9 +155,9 @@ func (s *Store) Create(ctx context.Context, ownerAddr, agent, subAddr, keyID str _, err = tx.Exec(ctx, `INSERT INTO fmsg_api_sub_account - (owner_addr, agent, sub_addr, key_id, key_hash, allowed_cidrs, key_expires_at, max_sub_accounts, updated_at) - VALUES ($1, $2, $3, $4, $5, $6::cidr[], $7, $8, now())`, - ownerAddr, agent, subAddr, keyID, keyHash, allowedCIDRs, keyExpiresAt, max) + (owner_addr, agent, sub_addr, grant_type, display_name, key_id, key_hash, allowed_cidrs, key_expires_at, max_sub_accounts, updated_at) + VALUES ($1, $2, $3, $4, NULLIF($5, ''), $6, $7, $8::cidr[], $9, $10, now())`, + ownerAddr, agent, subAddr, grantType, displayName, keyID, keyHash, allowedCIDRs, keyExpiresAt, max) if isUniqueViolation(err) { return ErrAlreadyExists } diff --git a/internal/handlers/subaccounts.go b/internal/handlers/subaccounts.go index 30dcd0f..28c8322 100644 --- a/internal/handlers/subaccounts.go +++ b/internal/handlers/subaccounts.go @@ -35,6 +35,8 @@ type rotateKeyInput struct { type subAccountResponse struct { Agent string `json:"agent"` Addr string `json:"addr"` + GrantType string `json:"grant_type"` + DisplayName string `json:"display_name,omitempty"` KeyID string `json:"key_id,omitempty"` AllowedCIDRs []string `json:"allowed_cidrs"` KeyExpiresAt string `json:"key_expires_at"` @@ -59,6 +61,8 @@ func (h *SubAccountHandler) List(c *gin.Context) { out = append(out, subAccountResponse{ Agent: a.Agent, Addr: a.Addr, + GrantType: a.GrantType, + DisplayName: a.DisplayName, KeyID: a.KeyID, AllowedCIDRs: a.AllowedCIDRs, KeyExpiresAt: a.KeyExpiresAt.UTC().Format(time.RFC3339), @@ -109,6 +113,7 @@ func (h *SubAccountHandler) Create(c *gin.Context) { c.JSON(http.StatusCreated, subAccountResponse{ Agent: in.Agent, Addr: subAddr, + GrantType: apiauth.GrantTypeDerivedSubAccount, KeyID: key.ID, AllowedCIDRs: in.AllowedCIDRs, KeyExpiresAt: expires.UTC().Format(time.RFC3339), @@ -126,12 +131,12 @@ func (h *SubAccountHandler) RotateKey(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent"}) return } - expectedSubAddr, err := apiauth.DeriveSubAccountAddr(owner, agent) + account, err := h.store.Get(c.Request.Context(), owner, agent) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent"}) + respondSubAccountStoreError(c, err) return } - if !checkAcceptingFmsgID(c, h.idURL, expectedSubAddr) { + if !checkAcceptingFmsgID(c, h.idURL, account.Addr) { return } @@ -167,6 +172,7 @@ func (h *SubAccountHandler) RotateKey(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "agent": agent, "addr": subAddr, + "grant_type": account.GrantType, "key_id": key.ID, "key_expires_at": expires.UTC().Format(time.RFC3339), "api_key": key.Value, @@ -228,7 +234,7 @@ func checkAcceptingFmsgID(c *gin.Context, idURL, addr string) bool { return false } if code == http.StatusNotFound { - c.JSON(http.StatusBadRequest, gin.H{"error": "sub-account not found in fmsgid"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "granted address not found in fmsgid"}) return false } if code != http.StatusOK { @@ -236,7 +242,7 @@ func checkAcceptingFmsgID(c *gin.Context, idURL, addr string) bool { return false } if !accepting { - c.JSON(http.StatusForbidden, gin.H{"error": "sub-account is not accepting new messages"}) + c.JSON(http.StatusForbidden, gin.H{"error": "granted address is not accepting new messages"}) return false } return true @@ -249,9 +255,9 @@ func respondSubAccountStoreError(c *gin.Context, err error) { case errors.Is(err, apiauth.ErrLimitExceeded): c.JSON(http.StatusForbidden, gin.H{"error": "sub-account limit exceeded"}) case errors.Is(err, apiauth.ErrNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": "sub-account not found"}) + c.JSON(http.StatusNotFound, gin.H{"error": "grant not found"}) default: - log.Printf("sub-account store error: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update sub-account"}) + log.Printf("grant store error: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update grant"}) } } diff --git a/internal/middleware/jwt.go b/internal/middleware/jwt.go index cec75fc..7e6e976 100644 --- a/internal/middleware/jwt.go +++ b/internal/middleware/jwt.go @@ -262,7 +262,7 @@ func authFailureFromError(err error) (int, string, bool) { case errors.Is(err, apiauth.ErrInvalidRemoteIP): return http.StatusUnauthorized, "invalid source IP", true case errors.Is(err, apiauth.ErrNotFound): - return http.StatusForbidden, "sub-account not authorised", true + return http.StatusForbidden, "grant not authorised", true } return 0, "", false }