rdev/internal/adapter/notify/admin_client.go
jordan 4f01015132
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat: implement project access enforcement and management API
- Fix no-op RequireProjectAccess middleware to enforce project_ids
- Apply project access middleware to all project-scoped routes
- Filter GET /projects by allowed project IDs for restricted keys
- Add GET /me endpoint with key identity, scopes, and project access info
- Add PATCH /keys/{id} for partial key updates (name, scopes, project_ids, allowed_ips, expires_in)
- Add GET/POST/DELETE /projects/{id}/access for project-centric access management
- Auto-grant creating key access when using POST /project/create-and-build
- Accept grant_to_key_ids in create-and-build to grant multiple keys on project creation
- Move newProvisionerWithDeps test helper from production code to test file

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 15:38:37 -07:00

225 lines
7.5 KiB
Go

// Package notify provides a notify service admin client for rdev.
// It manages accounts and send keys on behalf of projects.
package notify
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// notifyAdminAPI is the interface Provisioner uses to call the notify admin API.
// Extracted for testability.
type notifyAdminAPI interface {
createHost(ctx context.Context, hostSlug, strategy string) error
deleteHost(ctx context.Context, hostSlug string) error
createProvider(ctx context.Context, hostSlug, provider string, config map[string]string, priority, retryAttempts, retryBackoffMs int) error
createFromAddress(ctx context.Context, hostSlug, email, displayName string) error
createAccount(ctx context.Context, name string) (*accountResponse, error)
createSendKey(ctx context.Context, accountID, name string) (*apiKeyResponse, error)
grantHostAccess(ctx context.Context, hostSlug, accountID string) error
deleteAccount(ctx context.Context, accountID string) error
listAccounts(ctx context.Context) ([]accountResponse, error)
}
// adminClient calls the notify admin API to manage accounts and keys.
type adminClient struct {
baseURL string
adminKey string
httpClient *http.Client
}
func newAdminClient(baseURL, adminKey string) *adminClient {
return &adminClient{
baseURL: baseURL,
adminKey: adminKey,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// accountResponse is the shape returned by POST /admin/accounts.
type accountResponse struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
}
// apiKeyResponse is the shape returned by POST /admin/api-keys (full key only on creation).
type apiKeyResponse struct {
ID int `json:"id"`
Key string `json:"key"` // plaintext — only present on creation
KeyPrefix string `json:"key_prefix"` // e.g. "notify_send"
AccountID string `json:"account_id"`
KeyType string `json:"key_type"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
}
// listAccountsResponse is the shape returned by GET /admin/accounts.
type listAccountsResponse struct {
Items []accountResponse `json:"items"`
}
// createAccount creates a new notify account with the given name.
func (c *adminClient) createAccount(ctx context.Context, name string) (*accountResponse, error) {
payload := map[string]string{"name": name}
respBody, err := c.doRequest(ctx, http.MethodPost, "/admin/accounts", payload)
if err != nil {
return nil, fmt.Errorf("create account: %w", err)
}
var acct accountResponse
if err := json.Unmarshal(respBody, &acct); err != nil {
return nil, fmt.Errorf("unmarshal account response: %w", err)
}
return &acct, nil
}
// createSendKey creates a send API key for the given account.
// The plaintext key is only present in the response at creation time.
func (c *adminClient) createSendKey(ctx context.Context, accountID, name string) (*apiKeyResponse, error) {
payload := map[string]string{
"account_id": accountID,
"key_type": "send",
"name": name,
}
respBody, err := c.doRequest(ctx, http.MethodPost, "/admin/api-keys", payload)
if err != nil {
return nil, fmt.Errorf("create send key: %w", err)
}
var key apiKeyResponse
if err := json.Unmarshal(respBody, &key); err != nil {
return nil, fmt.Errorf("unmarshal key response: %w", err)
}
return &key, nil
}
// createHost creates a new notify sending host with the given slug and sending strategy.
func (c *adminClient) createHost(ctx context.Context, hostSlug, strategy string) error {
payload := map[string]string{"host": hostSlug, "strategy": strategy}
_, err := c.doRequest(ctx, http.MethodPost, "/admin/hosts", payload)
if err != nil {
return fmt.Errorf("create host %s: %w", hostSlug, err)
}
return nil
}
// deleteHost removes a notify host by its slug.
func (c *adminClient) deleteHost(ctx context.Context, hostSlug string) error {
_, err := c.doRequest(ctx, http.MethodDelete, "/admin/hosts/"+hostSlug, nil)
if err != nil {
return fmt.Errorf("delete host %s: %w", hostSlug, err)
}
return nil
}
// createProvider adds a sending provider to an existing host.
func (c *adminClient) createProvider(ctx context.Context, hostSlug, provider string, config map[string]string, priority, retryAttempts, retryBackoffMs int) error {
payload := map[string]any{
"provider": provider,
"config": config,
"priority": priority,
"retry_attempts": retryAttempts,
"retry_backoff_ms": retryBackoffMs,
}
_, err := c.doRequest(ctx, http.MethodPost, "/admin/hosts/"+hostSlug+"/providers", payload)
if err != nil {
return fmt.Errorf("create provider %s on host %s: %w", provider, hostSlug, err)
}
return nil
}
// createFromAddress registers a from-address on a host.
func (c *adminClient) createFromAddress(ctx context.Context, hostSlug, email, displayName string) error {
payload := map[string]string{"email": email, "display_name": displayName}
_, err := c.doRequest(ctx, http.MethodPost, "/admin/hosts/"+hostSlug+"/from-addresses", payload)
if err != nil {
return fmt.Errorf("create from-address %s on host %s: %w", email, hostSlug, err)
}
return nil
}
// grantHostAccess grants the given account access to send from the specified host slug.
func (c *adminClient) grantHostAccess(ctx context.Context, hostSlug, accountID string) error {
payload := map[string]string{"account_id": accountID}
_, err := c.doRequest(ctx, http.MethodPost, "/admin/hosts/"+hostSlug+"/accounts", payload)
if err != nil {
return fmt.Errorf("grant host access: %w", err)
}
return nil
}
// deleteAccount removes the notify account and all its keys.
func (c *adminClient) deleteAccount(ctx context.Context, accountID string) error {
_, err := c.doRequest(ctx, http.MethodDelete, "/admin/accounts/"+accountID, nil)
if err != nil {
return fmt.Errorf("delete account: %w", err)
}
return nil
}
// listAccounts returns all accounts in the notify service.
func (c *adminClient) listAccounts(ctx context.Context) ([]accountResponse, error) {
respBody, err := c.doRequest(ctx, http.MethodGet, "/admin/accounts", nil)
if err != nil {
return nil, fmt.Errorf("list accounts: %w", err)
}
var resp listAccountsResponse
if err := json.Unmarshal(respBody, &resp); err != nil {
return nil, fmt.Errorf("unmarshal accounts list: %w", err)
}
return resp.Items, nil
}
// doRequest executes an HTTP request against the notify admin API.
func (c *adminClient) doRequest(ctx context.Context, method, path string, bodyData any) ([]byte, error) {
var reqBody io.Reader
if bodyData != nil {
jsonBody, err := json.Marshal(bodyData)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
reqBody = bytes.NewReader(jsonBody)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.adminKey)
if bodyData != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("http do: %w", err)
}
defer func() { _ = resp.Body.Close() }()
// 204 No Content — success with no body (e.g., grant host access, delete)
if resp.StatusCode == http.StatusNoContent {
return nil, nil
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response body: %w", err)
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return respBody, nil
}
return nil, fmt.Errorf("notify admin API error (HTTP %d): %s", resp.StatusCode, string(respBody))
}