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
44 changes: 39 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,9 +176,11 @@ the plaintext API key once. Creation also requires the fmsgid service configured
by `FMSG_ID_URL`:

- `create` checks that the owner exists and accepts new messages, registers the
derived address with fmsgid's default quotas, and verifies it accepts new
messages before storing the key. Existing address settings are preserved;
quotas are independent of the owner's.
derived address with fmsgid in the owner's quota pool (it shares the owner's
quota), reinstates it if it was used before and stopped accepting new
messages, and verifies it accepts new messages before storing the key. It
prints `reinstated=true` for such an address. Its other settings are
preserved.
- `create-delegation` requires both the owner and delegated address to already
exist and accept new messages. It does not register either address.

Expand Down Expand Up @@ -401,8 +403,33 @@ EdDSA owner authentication.

The derived address is `@user_bot@domain`. `agent` may contain letters, digits,
dots, and hyphens, but not underscores. The derived address is automatically
registered with fmsgid (with default quotas) if it doesn't already exist there
— no manual fmsgid step is needed for derived sub-accounts.
registered with fmsgid, in the owner's quota pool so it shares the owner's
quota — no manual fmsgid step is needed for derived sub-accounts.

Only the owner can derive `@user_bot@domain`, so if the address already exists
in fmsgid it is the owner's from before — a sub-account they deleted — and is
reinstated: it accepts new messages again and its earlier messages are still
there. The response is the new grant, as from `GET /fmsg/sub-accounts/:agent`,
plus `api_key` (shown once) and `reinstated`, `true` for a reinstated address
and `false` for a new one:

```json
{
"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",
"api_key": "...",
"reinstated": true
}
```

If the grant can't be created (e.g. `409` or the sub-account limit), a
reinstated address is stopped accepting new messages again. Reinstating and
pooling need an fmsgid that supports `accepting_new` and `quota_pool` on
`POST /fmsgid`; with an older one `reinstated` is always `false`.

Delegated identities such as `@sales@example.com` are not created by this
self-service route. They are operator-created with `api-key create-delegation`
Expand Down Expand Up @@ -437,6 +464,13 @@ new plaintext key once. Requires `key_expires_at`. Does not touch
Deletes a grant row and revokes future token exchange. Existing JWTs for that
key are rejected on their next protected-route request.

For a derived sub-account the address is first stopped accepting new messages
in fmsgid (`accepting_new` false), so fmsg hosts refuse new mail to it; it and
its messages are kept, and creating the same sub-account again reinstates it.
If fmsgid can't be updated — unavailable, or a version without `accepting_new`
— the delete fails with `503` and the grant is kept, so it can be retried.
Delegated identities are addresses in their own right and are left as they are.

### GET `/fmsg/ws`

Upgrades the connection to a WebSocket over which the server pushes events that
Expand Down
18 changes: 16 additions & 2 deletions cmd/fmsg-webapi/apikey_cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,22 @@ func runAPIKeyCreate(ctx context.Context, args []string) error {
return err
}
// Match the self-service sub-account flow: derived addresses are created in
// fmsgid before their API key is persisted.
if err := middleware.RegisterFmsgID(idURL, subAddr); err != nil {
// fmsgid, in the owner's quota pool, before their API key is persisted, and
// one used before is reinstated.
reinstated, err := middleware.RegisterLinkedFmsgID(idURL, subAddr, *owner)
if err != nil {
return fmt.Errorf("registering derived address with fmsgid: %w", err)
}
granted := false
if reinstated {
defer func() {
if !granted {
if err := middleware.DisableFmsgID(idURL, subAddr); err != nil {
fmt.Fprintf(os.Stderr, "re-disabling reinstated address %s: %v\n", subAddr, err)
}
}
}()
}
if err := requireAcceptingCLIAddress(idURL, subAddr, "derived address"); err != nil {
return err
}
Expand All @@ -72,7 +84,9 @@ func runAPIKeyCreate(ctx context.Context, args []string) error {
if err := store.Create(ctx, *owner, *agent, subAddr, key.ID, hash, allowed, expires); err != nil {
return err
}
granted = true
printCLIKey(*owner, *agent, subAddr, key)
fmt.Printf("reinstated=%t\n", reinstated)
return nil
}

Expand Down
73 changes: 73 additions & 0 deletions cmd/fmsg-webapi/apikey_cli_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,3 +287,76 @@ func TestAPIKeyExchangeRejectsUnregisteredDerivedAddress(t *testing.T) {
t.Fatalf("unregistered address exchange status = %d", w.Code)
}
}

// TestAPIKeyCLICreateReinstates creates a derived sub-account whose address was
// used before: fmsgid reinstates it, the CLI says so, and when the grant can't
// be created the address is stopped accepting new messages again.
func TestAPIKeyCLICreateReinstates(t *testing.T) {
for _, conflict := range []bool{false, true} {
t.Run(fmt.Sprintf("conflict=%v", conflict), func(t *testing.T) {
database := newAPIKeyCLIDB(t)
const owner = "@alice@example.com"
const target = "@alice_bot@example.com"
if conflict {
key, err := apiauth.GenerateAPIKey()
if err != nil {
t.Fatal(err)
}
if err := apiauth.NewStore(database).Create(context.Background(), owner, "bot", target, key.ID,
apiauth.HashAPIKey(key.Value), []string{"127.0.0.0/8"}, time.Now().Add(time.Hour)); err != nil {
t.Fatal(err)
}
}
var mu sync.Mutex
accepting := false // the address exists and was disabled
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
switch {
case r.Method == http.MethodGet && r.URL.Path == "/fmsgid/"+owner:
_, _ = w.Write([]byte(`{"acceptingNew":true}`))
case r.Method == http.MethodGet && r.URL.Path == "/fmsgid/"+target:
_ = json.NewEncoder(w).Encode(map[string]bool{"acceptingNew": accepting})
case r.Method == http.MethodPost && r.URL.Path == "/fmsgid":
var body struct {
Address string `json:"address"`
AcceptingNew *bool `json:"accepting_new"`
QuotaPool string `json:"quota_pool"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Address != target || body.AcceptingNew == nil {
t.Errorf("unexpected fmsgid update: %+v, err=%v", body, err)
w.WriteHeader(http.StatusBadRequest)
return
}
if *body.AcceptingNew && body.QuotaPool != owner {
t.Errorf("registration quota_pool = %q, want %s", body.QuotaPool, owner)
}
reinstated := !accepting && *body.AcceptingNew
accepting = *body.AcceptingNew
_ = json.NewEncoder(w).Encode(map[string]bool{"created": false, "reinstated": reinstated})
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
t.Setenv("FMSG_ID_URL", server.URL)
output, err := runCapturedAPIKeyCLI(t, []string{"create", "-owner", owner, "-agent", "bot", "-cidr", "127.0.0.0/8",
"-expires", time.Now().Add(time.Hour).UTC().Format(time.RFC3339)})
mu.Lock()
defer mu.Unlock()
if conflict {
if err == nil {
t.Fatal("create succeeded despite an existing grant")
}
if accepting {
t.Fatal("address left reinstated although its grant wasn't created")
}
return
}
if err != nil || !strings.Contains(output, "\nreinstated=true\n") || !accepting {
t.Fatalf("create: err=%v accepting=%v output=%q", err, accepting, output)
}
})
}
}
74 changes: 63 additions & 11 deletions internal/handlers/subaccounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ type subAccountResponse struct {
APIKey string `json:"api_key,omitempty"`
}

type createSubAccountResponse struct {
subAccountResponse
// Reinstated is true when the derived address had been used before (its
// grant was deleted) and is now accepting new messages again, with its
// earlier messages still there.
Reinstated bool `json:"reinstated"`
}

func (h *SubAccountHandler) List(c *gin.Context) {
owner, ok := requireIdPOwner(c)
if !ok {
Expand Down Expand Up @@ -126,13 +134,25 @@ func (h *SubAccountHandler) Create(c *gin.Context) {
return
}
// Derived sub-accounts belong to an already-verified owner, so register
// them in fmsgid automatically rather than requiring a manual step.
// RegisterFmsgID never touches an address that already exists there.
if err := middleware.RegisterFmsgID(h.idURL, subAddr); err != nil {
// them in fmsgid automatically, in the owner's quota pool, rather than
// requiring a manual step. Only this owner can derive subAddr, so if it
// already exists there it was theirs before and is reinstated.
reinstated, err := middleware.RegisterLinkedFmsgID(h.idURL, subAddr, owner)
if err != nil {
log.Printf("sub-account create: fmsgid registration: addr=%s: %v", subAddr, err)
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "identity service unavailable"})
return
}
// undo a reinstatement if no grant comes of it, so a deleted address
// doesn't start accepting new messages again without one
granted := false
if reinstated {
defer func() {
if !granted {
disableAfterFailedCreate(h.idURL, subAddr)
}
}()
}
if !checkAcceptingFmsgID(c, h.idURL, subAddr) {
return
}
Expand All @@ -147,17 +167,30 @@ func (h *SubAccountHandler) Create(c *gin.Context) {
respondSubAccountStoreError(c, err)
return
}
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),
APIKey: key.Value,
granted = true
c.JSON(http.StatusCreated, createSubAccountResponse{
subAccountResponse: subAccountResponse{
Agent: in.Agent,
Addr: subAddr,
GrantType: apiauth.GrantTypeDerivedSubAccount,
KeyID: key.ID,
AllowedCIDRs: in.AllowedCIDRs,
KeyExpiresAt: expires.UTC().Format(time.RFC3339),
APIKey: key.Value,
},
Reinstated: reinstated,
})
}

// disableAfterFailedCreate stops a reinstated derived address accepting new
// messages again after its grant couldn't be created. It is best effort: the
// create has already failed, so a failure here is only logged.
func disableAfterFailedCreate(idURL, addr string) {
if err := middleware.DisableFmsgID(idURL, addr); err != nil {
log.Printf("sub-account create: re-disabling reinstated address after failure: addr=%s: %v", addr, err)
}
}

func (h *SubAccountHandler) RotateKey(c *gin.Context) {
owner, ok := requireIdPOwner(c)
if !ok {
Expand Down Expand Up @@ -256,6 +289,25 @@ func (h *SubAccountHandler) Delete(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent"})
return
}
account, err := h.store.Get(c.Request.Context(), owner, agent)
if err != nil {
respondSubAccountStoreError(c, err)
return
}
// A derived address exists only for its grant, so stop it accepting new
// messages: fmsg hosts then refuse mail to it. Do it before deleting the
// grant so that if fmsgid fails the grant is still there and the owner can
// retry; deleting first could leave the address accepting mail with
// nothing left to retry from. Its messages are kept and creating the
// sub-account again reinstates it. A delegated identity is an address in
// its own right and is left as it is.
if account.GrantType == apiauth.GrantTypeDerivedSubAccount {
if err := middleware.DisableFmsgID(h.idURL, account.Addr); err != nil {
log.Printf("sub-account delete: fmsgid disable: addr=%s: %v", account.Addr, err)
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "identity service unavailable"})
return
}
}
if err := h.store.Delete(c.Request.Context(), owner, agent); err != nil {
respondSubAccountStoreError(c, err)
return
Expand Down
Loading
Loading