All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 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>
225 lines
7.5 KiB
Go
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))
|
|
}
|