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
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ a JSON HTTP API. `pnpm dev` at the repo root starts both together.
just add the next-numbered file and restart.
- Auth is cookie-session based (`internal/auth`); `auth.Require(...)` wraps
a route and `requireCustomer(w, r)` pulls the resolved customer back out
inside the handler.
inside the handler. `auth.Require` also accepts a personal API key in
`Authorization: Bearer amelu_live_...`, acting as the key's owner. The
account surface and API key management use `auth.RequireSession(...)`
instead, which is cookie-only, so a leaked key can't mint further keys or
take over the account.
- Optional integrations all follow the same convention: missing
config/API key means the feature reports "unavailable" at request time,
never a startup failure. See `internal/config/config.go`. Currently:
Expand Down
27 changes: 21 additions & 6 deletions backend/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,12 +180,20 @@ func main() {
mux.HandleFunc("GET /api/invitations/{token}", app.GetInvitation)
mux.HandleFunc("POST /api/invitations/{token}/accept", app.AcceptInvitation)

mux.HandleFunc("GET /api/account", auth.Require(app.Store, app.Me))
mux.HandleFunc("PATCH /api/account/name", auth.Require(app.Store, app.UpdateAccountName))
mux.HandleFunc("PATCH /api/account/profile", auth.Require(app.Store, app.UpdateAccountProfile))
mux.HandleFunc("PATCH /api/account/email", auth.Require(app.Store, app.UpdateAccountEmail))
mux.HandleFunc("PATCH /api/account/password", auth.Require(app.Store, app.UpdateAccountPassword))
mux.HandleFunc("DELETE /api/account", auth.Require(app.Store, app.TerminateAccount))
// Session-only (auth.RequireSession, not auth.Require): the account
// surface and API key management are the two things an API key must not
// be able to reach, so a leaked key can't mint more keys, move the
// sign-in email, or terminate the account.
mux.HandleFunc("GET /api/account", auth.RequireSession(app.Store, app.Me))
mux.HandleFunc("PATCH /api/account/name", auth.RequireSession(app.Store, app.UpdateAccountName))
mux.HandleFunc("PATCH /api/account/profile", auth.RequireSession(app.Store, app.UpdateAccountProfile))
mux.HandleFunc("PATCH /api/account/email", auth.RequireSession(app.Store, app.UpdateAccountEmail))
mux.HandleFunc("PATCH /api/account/password", auth.RequireSession(app.Store, app.UpdateAccountPassword))
mux.HandleFunc("DELETE /api/account", auth.RequireSession(app.Store, app.TerminateAccount))

mux.HandleFunc("GET /api/account/api-keys", auth.RequireSession(app.Store, app.ListAPIKeys))
mux.HandleFunc("POST /api/account/api-keys", auth.RequireSession(app.Store, app.CreateAPIKey))
mux.HandleFunc("DELETE /api/account/api-keys/{id}", auth.RequireSession(app.Store, app.RevokeAPIKey))

mux.HandleFunc("POST /api/domains", auth.Require(app.Store, app.CreateDomain))
mux.HandleFunc("GET /api/domains", auth.Require(app.Store, app.ListDomains))
Expand Down Expand Up @@ -244,6 +252,13 @@ func main() {
mux.HandleFunc("POST /api/mailboxes/{id}/suspend", auth.Require(app.Store, app.SuspendMailbox))
mux.HandleFunc("POST /api/mailboxes/{id}/activate", auth.Require(app.Store, app.ActivateMailbox))

// Mail contents, the surface an automated caller (API key) actually
// wants: read and send as a mailbox over HTTP instead of IMAP/SMTP.
// Owner and admin only - see authz.CanAccessMailboxContents.
mux.HandleFunc("GET /api/mailboxes/{id}/messages", auth.Require(app.Store, app.ListMailboxMessages))
mux.HandleFunc("GET /api/mailboxes/{id}/messages/{messageId}", auth.Require(app.Store, app.GetMailboxMessage))
mux.HandleFunc("POST /api/mailboxes/{id}/messages", auth.Require(app.Store, app.SendMailboxMessage))

mux.HandleFunc("GET /api/mailboxes/{id}/activity", auth.Require(app.Store, app.GetMailboxActivity))
mux.HandleFunc("GET /api/mailboxes/{id}/logs", auth.Require(app.Store, app.GetMailboxRecentLogs))

Expand Down
52 changes: 52 additions & 0 deletions backend/internal/auth/apikey.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package auth

import (
"crypto/rand"
"encoding/base64"
"net/http"
"strings"
)

// KeyPrefix is the fixed head of every API key. It exists so a leaked key is
// recognisable as an Amelu credential in logs and secret scanners, and so the
// dashboard can show enough of a key to tell two apart without storing any
// part that would help forge one.
const KeyPrefix = "amelu_live_"

// prefixRandomChars is how much of the random tail is kept alongside the
// fixed prefix for display. Twelve of the 43 base64 characters is far too
// little to brute-force the rest from.
const prefixRandomChars = 6

// NewAPIKey returns a fresh key to hand to the caller once, its SHA-256 hash
// for storage, and the display prefix. Same rule as sessions: the raw key is
// never persisted.
func NewAPIKey() (raw string, hash string, prefix string, err error) {
b := make([]byte, tokenNumBytes)
if _, err = rand.Read(b); err != nil {
return "", "", "", err
}
random := base64.RawURLEncoding.EncodeToString(b)
raw = KeyPrefix + random
return raw, HashToken(raw), KeyPrefix + random[:prefixRandomChars], nil
}

// APIKeyFromRequest pulls a key out of "Authorization: Bearer ...". Anything
// that isn't a bearer token carrying our prefix is reported as absent rather
// than as a bad key, so a request holding some other Authorization header
// still falls through to cookie auth.
func APIKeyFromRequest(r *http.Request) (string, bool) {
header := r.Header.Get("Authorization")
if header == "" {
return "", false
}
key, ok := strings.CutPrefix(header, "Bearer ")
if !ok {
return "", false
}
key = strings.TrimSpace(key)
if !strings.HasPrefix(key, KeyPrefix) {
return "", false
}
return key, true
}
66 changes: 66 additions & 0 deletions backend/internal/auth/apikey_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package auth

import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)

func TestNewAPIKey_PrefixAndHash(t *testing.T) {
raw, hash, prefix, err := NewAPIKey()
if err != nil {
t.Fatalf("NewAPIKey: %v", err)
}
if !strings.HasPrefix(raw, KeyPrefix) {
t.Fatalf("raw key %q missing prefix %q", raw, KeyPrefix)
}
if !strings.HasPrefix(raw, prefix) {
t.Fatalf("display prefix %q is not a prefix of the key", prefix)
}
if len(prefix) != len(KeyPrefix)+prefixRandomChars {
t.Fatalf("prefix %q has unexpected length %d", prefix, len(prefix))
}
if hash != HashToken(raw) {
t.Fatal("returned hash does not match HashToken of the raw key")
}
if strings.Contains(hash, raw) {
t.Fatal("hash must not contain the raw key")
}

other, _, _, err := NewAPIKey()
if err != nil {
t.Fatalf("NewAPIKey: %v", err)
}
if other == raw {
t.Fatal("two generated keys must not be identical")
}
}

func TestAPIKeyFromRequest(t *testing.T) {
cases := []struct {
name string
header string
want string
wantOK bool
}{
{"no header", "", "", false},
{"bearer amelu key", "Bearer " + KeyPrefix + "abc123", KeyPrefix + "abc123", true},
{"some other bearer token", "Bearer github_pat_abc", "", false},
{"basic auth", "Basic dXNlcjpwYXNz", "", false},
{"missing bearer scheme", KeyPrefix + "abc123", "", false},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/domains", nil)
if tc.header != "" {
req.Header.Set("Authorization", tc.header)
}
got, ok := APIKeyFromRequest(req)
if ok != tc.wantOK || got != tc.want {
t.Fatalf("got (%q, %v), want (%q, %v)", got, ok, tc.want, tc.wantOK)
}
})
}
}
35 changes: 32 additions & 3 deletions backend/internal/auth/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,39 @@ type contextKey int

const customerContextKey contextKey = 0

// Require looks up the session cookie, resolves it to a customer via store,
// and attaches the customer to the request context. Responds 401 if the
// cookie is missing, invalid, or expired.
// Require resolves the caller to a customer and attaches it to the request
// context, accepting either the session cookie or an API key in
// "Authorization: Bearer" (see apikey.go). Responds 401 if neither is present
// or valid.
//
// A key acts as the customer that owns it, with that customer's organization
// role - there is no separate permission model for keys. What a key cannot do
// is reach the routes wrapped in RequireSession below.
func Require(store *db.Store, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if key, ok := APIKeyFromRequest(r); ok {
customer, err := store.GetCustomerByAPIKeyHash(r.Context(), HashToken(key))
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), customerContextKey, customer)))
return
}
requireSession(store, next)(w, r)
}
}

// RequireSession is Require without the API key path: the caller must hold a
// session cookie. It guards the account surface (sign-in email, password,
// account termination) and API key management itself, so a leaked key can
// neither mint further keys nor take the account over - the blast radius of a
// key stops at the product API.
func RequireSession(store *db.Store, next http.HandlerFunc) http.HandlerFunc {
return requireSession(store, next)
}

func requireSession(store *db.Store, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token, err := TokenFromRequest(r)
if err != nil {
Expand Down
10 changes: 10 additions & 0 deletions backend/internal/authz/authz.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ func CanManageMailboxes(role string) bool {
return role == db.RoleOwner || role == db.RoleAdmin || role == db.RoleHelpdesk
}

// CanAccessMailboxContents covers reading and sending the actual mail in a
// mailbox over the API - owner and admin only. Deliberately narrower than
// CanManageMailboxes: helpdesk can reset a mailbox password to help someone
// back in, but that is a support action the mailbox owner can see happened,
// whereas reading their mail silently is not. read_only means "view the
// configuration", never "read the correspondence".
func CanAccessMailboxContents(role string) bool {
return role == db.RoleOwner || role == db.RoleAdmin
}

// CanManageBilling covers viewing and changing billing/subscription state -
// owner and billing only.
func CanManageBilling(role string) bool {
Expand Down
107 changes: 107 additions & 0 deletions backend/internal/db/api_keys.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package db

import (
"context"
"database/sql"
"errors"
"time"
)

type APIKey struct {
ID string
CustomerID string
Name string
KeyHash string
Prefix string
LastUsedAt sql.NullTime
RevokedAt sql.NullTime
CreatedAt time.Time
}

const apiKeyColumns = `id, customer_id, name, key_hash, prefix, last_used_at, revoked_at, created_at`

func scanAPIKey(row interface {
Scan(dest ...any) error
}) (*APIKey, error) {
k := &APIKey{}
err := row.Scan(&k.ID, &k.CustomerID, &k.Name, &k.KeyHash, &k.Prefix, &k.LastUsedAt, &k.RevokedAt, &k.CreatedAt)
return k, err
}

func (s *Store) CreateAPIKey(ctx context.Context, customerID, name, keyHash, prefix string) (*APIKey, error) {
row := s.conn.QueryRowContext(ctx, `
INSERT INTO api_keys (customer_id, name, key_hash, prefix)
VALUES ($1, $2, $3, $4)
RETURNING `+apiKeyColumns,
customerID, name, keyHash, prefix)
return scanAPIKey(row)
}

// ListAPIKeys returns the customer's keys, revoked ones excluded - a revoked
// key can never be used or un-revoked, so keeping it on screen would only
// invite the reader to think it still means something.
func (s *Store) ListAPIKeys(ctx context.Context, customerID string) ([]APIKey, error) {
rows, err := s.conn.QueryContext(ctx, `
SELECT `+apiKeyColumns+`
FROM api_keys
WHERE customer_id = $1 AND revoked_at IS NULL
ORDER BY created_at DESC
`, customerID)
if err != nil {
return nil, err
}
defer rows.Close()

var out []APIKey
for rows.Next() {
k, err := scanAPIKey(rows)
if err != nil {
return nil, err
}
out = append(out, *k)
}
return out, rows.Err()
}

func (s *Store) RevokeAPIKey(ctx context.Context, customerID, keyID string) error {
res, err := s.conn.ExecContext(ctx, `
UPDATE api_keys SET revoked_at = now()
WHERE id = $1 AND customer_id = $2 AND revoked_at IS NULL
`, keyID, customerID)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
}

// GetCustomerByAPIKeyHash is the API-key equivalent of
// GetCustomerBySessionToken. last_used_at is updated on the way through so
// the list page can show whether a key is still in use; it's a separate
// statement rather than a CTE because a failed touch must not fail the
// request.
func (s *Store) GetCustomerByAPIKeyHash(ctx context.Context, keyHash string) (*Customer, error) {
c := &Customer{}
var keyID string
err := s.conn.QueryRowContext(ctx, `
SELECT k.id, c.id, c.email, c.name, c.password_hash, c.plan_tier_id, c.organization_id, c.last_sign_in_at, c.created_at
FROM api_keys k
JOIN customers c ON c.id = k.customer_id
WHERE k.key_hash = $1 AND k.revoked_at IS NULL
`, keyHash).Scan(&keyID, &c.ID, &c.Email, &c.Name, &c.PasswordHash, &c.PlanTierID, &c.OrganizationID, &c.LastSignInAt, &c.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}

s.conn.ExecContext(ctx, `UPDATE api_keys SET last_used_at = now() WHERE id = $1`, keyID)
return c, nil
}
19 changes: 19 additions & 0 deletions backend/internal/db/migrations/0025_api_keys.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- Personal API keys. Like sessions (0001) and organization_invitations
-- (0022), only the SHA-256 hash of the key is stored - the raw key is shown
-- to the caller once at creation and never again.
--
-- prefix is the human-readable head of the key ("amelu_live_" plus the first
-- few random characters), stored in the clear purely so the list page can
-- show which key is which. It is not enough to authenticate with.
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
name TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
prefix TEXT NOT NULL,
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX api_keys_customer_id_idx ON api_keys (customer_id, created_at DESC);
Loading
Loading