Skip to content

Commit fcb7bb7

Browse files
authored
allow users to be delegated by others (#27)
1 parent 3db4452 commit fcb7bb7

8 files changed

Lines changed: 284 additions & 46 deletions

File tree

‎README.md‎

Lines changed: 62 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -80,33 +80,37 @@ deployment.
8080
### API Keys And First-Party JWTs
8181

8282
Active when `FMSG_API_TOKEN_ED25519_PRIVATE_KEY` is set. Programmatic clients
83-
authenticate with opaque API keys bound to sub-account addresses. The server
84-
stores only API-key hashes and exchanges valid keys for short-lived Ed25519 JWTs.
83+
authenticate with opaque API keys bound to API-access grants. A grant may be a
84+
derived sub-account such as `@alice_bot@example.com`, or an explicit delegated
85+
identity such as `@sales@example.com`. The server stores only API-key hashes and
86+
exchanges valid keys for short-lived Ed25519 JWTs.
8587

8688
API keys are sent only to `POST /fmsg/token`:
8789

8890
```http
8991
Authorization: Bearer fmsgk_<key_id>_<secret>
9092
```
9193

92-
The returned JWT contains `sub` (the sub-account address), `owner`, `api_key_id`,
94+
The returned JWT contains `sub` (the granted address), `owner`, `api_key_id`,
9395
`iss`, `aud`, `iat`, and `exp`. Protected routes re-check the backing key row on
94-
each request, so deleting a sub-account or expiring its key invalidates existing
96+
each request, so deleting a grant or expiring its key invalidates existing
9597
tokens before their normal expiry.
9698

9799
An RS256-authenticated owner can perform normal message routes as one of their
98-
sub-accounts without changing request bodies:
100+
granted identities without changing request bodies:
99101

100102
```http
101103
X-FMSG-Act-As: @user_bot@example.com
102104
```
103105

104-
The requested sub-account must be owned by the authenticated user and must exist
106+
The requested address must be granted to the authenticated user and must exist
105107
in fmsgid.
106108

107-
Apply [api_keys.sql](api_keys.sql) before enabling API-key auth.
109+
Apply [api_keys.sql](api_keys.sql) before enabling API-key auth. Existing
110+
deployments that already applied the earlier API-key table should apply
111+
[api_keys_delegation.sql](api_keys_delegation.sql).
108112

109-
To set a custom per-owner sub-account limit, insert an owner config row:
113+
To set a custom per-owner grant limit, insert an owner config row:
110114

111115
```sql
112116
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;
117121

118122
Operators can bootstrap or rotate keys without RS256 by using the built-in CLI
119123
command. It uses the standard `PG*` connection environment variables and prints
120-
the plaintext API key once:
124+
the plaintext API key once.
125+
126+
Derived sub-account:
121127

122128
```bash
123129
go run ./cmd/fmsg-webapi api-key create \
@@ -132,6 +138,22 @@ go run ./cmd/fmsg-webapi api-key rotate \
132138
-expires 2027-03-31T00:00:00Z
133139
```
134140

141+
Delegated identity:
142+
143+
```bash
144+
go run ./cmd/fmsg-webapi api-key create-delegation \
145+
-owner @mark@fmsg.io \
146+
-agent sales \
147+
-addr @sales@fmsg.io \
148+
-cidr 203.0.113.0/24 \
149+
-expires 2026-12-31T00:00:00Z
150+
151+
go run ./cmd/fmsg-webapi api-key rotate-delegation \
152+
-owner @mark@fmsg.io \
153+
-agent sales \
154+
-expires 2027-03-31T00:00:00Z
155+
```
156+
135157
## Building
136158

137159
Requires **Go 1.25** or newer.
@@ -215,10 +237,10 @@ the application.
215237
| `GET` | `/fmsg/sent` | List authored messages (sent + drafts) |
216238
| `GET` | `/fmsg/ws` | WebSocket for pushed event notifications |
217239
| `POST` | `/fmsg/token` | Exchange an API key for a JWT |
218-
| `GET` | `/fmsg/sub-accounts` | List owned sub-accounts |
219-
| `POST` | `/fmsg/sub-accounts` | Create a sub-account API key |
220-
| `POST` | `/fmsg/sub-accounts/:agent/rotate-key` | Rotate a sub-account API key |
221-
| `DELETE` | `/fmsg/sub-accounts/:agent` | Delete a sub-account |
240+
| `GET` | `/fmsg/sub-accounts` | List owned API-access grants |
241+
| `POST` | `/fmsg/sub-accounts` | Create a derived sub-account API key |
242+
| `POST` | `/fmsg/sub-accounts/:agent/rotate-key` | Rotate a grant API key |
243+
| `DELETE` | `/fmsg/sub-accounts/:agent` | Delete a grant |
222244
| `POST` | `/fmsg` | Create a draft message |
223245
| `GET` | `/fmsg/:id` | Retrieve a message |
224246
| `PUT` | `/fmsg/:id` | Update a draft message |
@@ -246,7 +268,7 @@ Exchanges an opaque API key for a short-lived JWT.
246268
**Authentication:** `Authorization: Bearer fmsgk_<key_id>_<secret>`.
247269

248270
The key must be unexpired, match the stored hash, be used from an allowed CIDR,
249-
and belong to a sub-account that exists in fmsgid.
271+
and belong to a granted address that exists in fmsgid.
250272

251273
**Response:**
252274

@@ -261,7 +283,10 @@ and belong to a sub-account that exists in fmsgid.
261283

262284
### GET `/fmsg/sub-accounts`
263285

264-
Lists sub-accounts owned by the RS256-authenticated user.
286+
Lists API-access grants owned by the RS256-authenticated user. Grants with
287+
`grant_type: "derived_sub_account"` use the `@user_agent@domain` convention.
288+
Grants with `grant_type: "delegated_identity"` are explicit operator-created
289+
delegations to arbitrary fmsg addresses.
265290

266291
**Response:**
267292

@@ -272,18 +297,28 @@ Lists sub-accounts owned by the RS256-authenticated user.
272297
{
273298
"agent": "bot",
274299
"addr": "@alice_bot@example.com",
300+
"grant_type": "derived_sub_account",
275301
"key_id": "abc",
276302
"allowed_cidrs": ["203.0.113.0/24"],
277303
"key_expires_at": "2026-12-31T00:00:00Z"
304+
},
305+
{
306+
"agent": "sales",
307+
"addr": "@sales@example.com",
308+
"grant_type": "delegated_identity",
309+
"display_name": "Sales mailbox",
310+
"key_id": "def",
311+
"allowed_cidrs": ["203.0.113.0/24"],
312+
"key_expires_at": "2026-12-31T00:00:00Z"
278313
}
279314
]
280315
}
281316
```
282317

283318
### POST `/fmsg/sub-accounts`
284319

285-
Creates a sub-account and returns its plaintext API key once. Requires RS256
286-
owner authentication.
320+
Creates a derived sub-account and returns its plaintext API key once. Requires
321+
RS256 owner authentication.
287322

288323
```json
289324
{
@@ -296,15 +331,21 @@ owner authentication.
296331
The derived address is `@user_bot@domain`. `agent` may contain letters, digits,
297332
dots, and hyphens, but not underscores.
298333

334+
Delegated identities such as `@sales@example.com` are not created by this
335+
self-service route. They are operator-created with `api-key create-delegation`
336+
after the operator has confirmed the owner is allowed to manage the delegated
337+
address.
338+
299339
### POST `/fmsg/sub-accounts/:agent/rotate-key`
300340

301-
Rotates a sub-account API key and returns the new plaintext key once. Requires
302-
`key_expires_at`; `allowed_cidrs` may be supplied to replace the existing ranges.
341+
Rotates any grant API key owned by the RS256-authenticated user and returns the
342+
new plaintext key once. Requires `key_expires_at`; `allowed_cidrs` may be
343+
supplied to replace the existing ranges.
303344

304345
### DELETE `/fmsg/sub-accounts/:agent`
305346

306-
Deletes a sub-account row and revokes future token exchange. Existing JWTs for
307-
that key are rejected on their next protected-route request.
347+
Deletes a grant row and revokes future token exchange. Existing JWTs for that
348+
key are rejected on their next protected-route request.
308349

309350
### GET `/fmsg/ws`
310351

‎api_keys.sql‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ CREATE TABLE IF NOT EXISTS fmsg_api_sub_account (
22
owner_addr varchar(255) NOT NULL,
33
agent varchar(64) NOT NULL,
44
sub_addr varchar(255),
5+
grant_type text NOT NULL DEFAULT 'derived_sub_account',
6+
display_name text,
57
key_id varchar(64),
68
key_hash bytea,
79
allowed_cidrs cidr[],
@@ -10,11 +12,11 @@ CREATE TABLE IF NOT EXISTS fmsg_api_sub_account (
1012
created_at timestamptz NOT NULL DEFAULT now(),
1113
updated_at timestamptz NOT NULL DEFAULT now(),
1214
PRIMARY KEY (owner_addr, agent),
13-
UNIQUE (sub_addr),
1415
UNIQUE (key_id),
1516
CHECK (max_sub_accounts > 0),
17+
CHECK (grant_type IN ('derived_sub_account', 'delegated_identity')),
1618
CHECK (
17-
(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)
19+
(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)
1820
OR
1921
(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)
2022
),
@@ -26,3 +28,7 @@ CREATE INDEX IF NOT EXISTS fmsg_api_sub_account_owner_idx
2628
2729
CREATE INDEX IF NOT EXISTS fmsg_api_sub_account_sub_idx
2830
ON fmsg_api_sub_account ((lower(sub_addr)));
31+
32+
CREATE UNIQUE INDEX IF NOT EXISTS fmsg_api_sub_account_owner_sub_unique
33+
ON fmsg_api_sub_account ((lower(owner_addr)), (lower(sub_addr)))
34+
WHERE agent <> '';

‎api_keys_delegation.sql‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
ALTER TABLE fmsg_api_sub_account
2+
ADD COLUMN IF NOT EXISTS grant_type text NOT NULL DEFAULT 'derived_sub_account';
3+
4+
ALTER TABLE fmsg_api_sub_account
5+
ADD COLUMN IF NOT EXISTS display_name text;
6+
7+
ALTER TABLE fmsg_api_sub_account
8+
DROP CONSTRAINT IF EXISTS fmsg_api_sub_account_sub_addr_key;
9+
10+
DO $$
11+
BEGIN
12+
IF NOT EXISTS (
13+
SELECT 1
14+
FROM pg_constraint
15+
WHERE conname = 'fmsg_api_sub_account_grant_type_check'
16+
) THEN
17+
ALTER TABLE fmsg_api_sub_account
18+
ADD CONSTRAINT fmsg_api_sub_account_grant_type_check
19+
CHECK (grant_type IN ('derived_sub_account', 'delegated_identity'));
20+
END IF;
21+
END $$;
22+
23+
CREATE UNIQUE INDEX IF NOT EXISTS fmsg_api_sub_account_owner_sub_unique
24+
ON fmsg_api_sub_account ((lower(owner_addr)), (lower(sub_addr)))
25+
WHERE agent <> '';

‎cmd/fmsg-webapi/apikey_cli.go‎

Lines changed: 90 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,17 @@ import (
1515

1616
func runAPIKeyCLI(ctx context.Context, args []string) error {
1717
if len(args) == 0 {
18-
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")
18+
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")
1919
}
2020
switch args[0] {
2121
case "create":
2222
return runAPIKeyCreate(ctx, args[1:])
2323
case "rotate":
2424
return runAPIKeyRotate(ctx, args[1:])
25+
case "create-delegation":
26+
return runAPIKeyCreateDelegation(ctx, args[1:])
27+
case "rotate-delegation":
28+
return runAPIKeyRotateDelegation(ctx, args[1:])
2529
default:
2630
return fmt.Errorf("unknown api-key command %q", args[0])
2731
}
@@ -93,17 +97,96 @@ func runAPIKeyRotate(ctx context.Context, args []string) error {
9397
return nil
9498
}
9599

100+
func runAPIKeyCreateDelegation(ctx context.Context, args []string) error {
101+
fs := flag.NewFlagSet("api-key create-delegation", flag.ContinueOnError)
102+
fs.SetOutput(os.Stderr)
103+
owner := fs.String("owner", "", "owner fmsg address")
104+
agent := fs.String("agent", "", "delegation label")
105+
addr := fs.String("addr", "", "delegated fmsg address")
106+
displayName := fs.String("display-name", "", "optional display name")
107+
cidrs := fs.String("cidr", "", "comma-separated allowed CIDR ranges")
108+
expiresRaw := fs.String("expires", "", "API key expiry as RFC3339 timestamp")
109+
if err := fs.Parse(args); err != nil {
110+
return err
111+
}
112+
113+
allowed, expires, key, hash, err := prepareCLIGrantInputs(*owner, *agent, *cidrs, *expiresRaw)
114+
if err != nil {
115+
return err
116+
}
117+
if len(allowed) == 0 {
118+
return fmt.Errorf("cidr is required for create-delegation")
119+
}
120+
if !middleware.IsValidAddr(*addr) {
121+
return fmt.Errorf("addr must be an fmsg address")
122+
}
123+
database, err := db.New(ctx, "")
124+
if err != nil {
125+
return err
126+
}
127+
defer database.Close()
128+
129+
store := apiauth.NewStore(database)
130+
if err := store.CreateDelegated(ctx, *owner, *agent, *addr, *displayName, key.ID, hash, allowed, expires); err != nil {
131+
return err
132+
}
133+
printCLIKey(*owner, *agent, *addr, key)
134+
return nil
135+
}
136+
137+
func runAPIKeyRotateDelegation(ctx context.Context, args []string) error {
138+
fs := flag.NewFlagSet("api-key rotate-delegation", flag.ContinueOnError)
139+
fs.SetOutput(os.Stderr)
140+
owner := fs.String("owner", "", "owner fmsg address")
141+
agent := fs.String("agent", "", "delegation label")
142+
cidrs := fs.String("cidr", "", "comma-separated allowed CIDR ranges; omit to keep existing")
143+
expiresRaw := fs.String("expires", "", "API key expiry as RFC3339 timestamp")
144+
if err := fs.Parse(args); err != nil {
145+
return err
146+
}
147+
148+
allowed, expires, key, hash, err := prepareCLIGrantInputs(*owner, *agent, *cidrs, *expiresRaw)
149+
if err != nil {
150+
return err
151+
}
152+
database, err := db.New(ctx, "")
153+
if err != nil {
154+
return err
155+
}
156+
defer database.Close()
157+
158+
store := apiauth.NewStore(database)
159+
replaceCIDRs := strings.TrimSpace(*cidrs) != ""
160+
subAddr, err := store.RotateKey(ctx, *owner, *agent, key.ID, hash, expires, allowed, replaceCIDRs)
161+
if err != nil {
162+
return err
163+
}
164+
printCLIKey(*owner, *agent, subAddr, key)
165+
return nil
166+
}
167+
96168
func prepareCLIKeyInputs(owner, agent, cidrsRaw, expiresRaw string) (string, []string, time.Time, apiauth.APIKey, []byte, error) {
97-
if !middleware.IsValidAddr(owner) {
98-
return "", nil, time.Time{}, apiauth.APIKey{}, nil, fmt.Errorf("owner must be an fmsg address")
169+
allowed, expires, key, hash, err := prepareCLIGrantInputs(owner, agent, cidrsRaw, expiresRaw)
170+
if err != nil {
171+
return "", nil, time.Time{}, apiauth.APIKey{}, nil, err
99172
}
100173
subAddr, err := apiauth.DeriveSubAccountAddr(owner, agent)
101174
if err != nil {
102175
return "", nil, time.Time{}, apiauth.APIKey{}, nil, err
103176
}
177+
return subAddr, allowed, expires, key, hash, nil
178+
}
179+
180+
func prepareCLIGrantInputs(owner, agent, cidrsRaw, expiresRaw string) ([]string, time.Time, apiauth.APIKey, []byte, error) {
181+
if !middleware.IsValidAddr(owner) {
182+
return nil, time.Time{}, apiauth.APIKey{}, nil, fmt.Errorf("owner must be an fmsg address")
183+
}
184+
if err := apiauth.ValidateAgent(agent); err != nil {
185+
return nil, time.Time{}, apiauth.APIKey{}, nil, err
186+
}
104187
expires, err := time.Parse(time.RFC3339, expiresRaw)
105188
if err != nil || !expires.After(time.Now()) {
106-
return "", nil, time.Time{}, apiauth.APIKey{}, nil, fmt.Errorf("expires must be a future RFC3339 timestamp")
189+
return nil, time.Time{}, apiauth.APIKey{}, nil, fmt.Errorf("expires must be a future RFC3339 timestamp")
107190
}
108191
var allowed []string
109192
if strings.TrimSpace(cidrsRaw) != "" {
@@ -113,14 +196,14 @@ func prepareCLIKeyInputs(owner, agent, cidrsRaw, expiresRaw string) (string, []s
113196
}
114197
if len(allowed) > 0 {
115198
if err := apiauth.ValidateCIDRs(allowed); err != nil {
116-
return "", nil, time.Time{}, apiauth.APIKey{}, nil, fmt.Errorf("invalid CIDR: %w", err)
199+
return nil, time.Time{}, apiauth.APIKey{}, nil, fmt.Errorf("invalid CIDR: %w", err)
117200
}
118201
}
119202
key, err := apiauth.GenerateAPIKey()
120203
if err != nil {
121-
return "", nil, time.Time{}, apiauth.APIKey{}, nil, err
204+
return nil, time.Time{}, apiauth.APIKey{}, nil, err
122205
}
123-
return subAddr, allowed, expires, key, apiauth.HashAPIKey(key.Value), nil
206+
return allowed, expires, key, apiauth.HashAPIKey(key.Value), nil
124207
}
125208

126209
func printCLIKey(owner, agent, subAddr string, key apiauth.APIKey) {

0 commit comments

Comments
 (0)