Skip to content

Commit e62d8b2

Browse files
committed
feat: secrets api and injection
1 parent 048dd9a commit e62d8b2

15 files changed

Lines changed: 920 additions & 15 deletions

forge-go/agent/server.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,8 @@ func StartServer(ctx context.Context, cfg *ServerConfig) error {
339339
httpServer := api.NewServer(db, statusStore, controlPlane, msgBackend, fileStore, cfg.ListenAddress).
340340
WithObservability(cfg.TelemetryMode, cfg.TelemetrySQLiteDBPath).
341341
WithModelFit("", cfg.DependencyConfig, nil).
342-
WithOAuth()
342+
WithOAuth().
343+
WithSecrets()
343344
wg.Add(1)
344345
go func() {
345346
defer wg.Done()

forge-go/api/oauth.go

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,6 @@ func (s *Server) registerOAuthRoutes(router *gin.Engine, prefix string) {
4343
// Single, provider- and org-agnostic callback for every provider: the flow is
4444
// identified entirely by the opaque state inside ExchangeCode.
4545
router.GET(prefix+"/oauth/callback", wrapHTTP(s.handleOAuthCallback()))
46-
// Backward-compat alias for providers registered with the older org-scoped
47-
// callback URL. Shares the same state-driven handler.
48-
router.GET(prefix+"/oauth/organizations/:org_id/providers/:provider_id/callback", wrapHTTPWithPathValues(s.handleOAuthCallback(), "org_id", "provider_id"))
4946
router.GET(prefix+"/oauth/organizations/:org_id/providers/:provider_id/status", wrapHTTPWithPathValues(s.handleOAuthStatus(), "org_id", "provider_id"))
5047
router.DELETE(prefix+"/oauth/organizations/:org_id/providers/:provider_id", wrapHTTPWithPathValues(s.handleOAuthDisconnect(), "org_id", "provider_id"))
5148
}

forge-go/api/secrets.go

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package api
2+
3+
import (
4+
"encoding/base64"
5+
"errors"
6+
"fmt"
7+
"net/http"
8+
"strings"
9+
10+
"github.com/gin-gonic/gin"
11+
"github.com/rustic-ai/forge/forge-go/secrets"
12+
)
13+
14+
// maxSecretValueBytes caps the size of a stored secret value.
15+
const maxSecretValueBytes = 64 * 1024
16+
17+
type createSecretRequest struct {
18+
Name string `json:"name"`
19+
// Value is the secret, base64-encoded (standard encoding). The handler
20+
// decodes it before storage; the raw bytes are never persisted encoded.
21+
Value string `json:"value"`
22+
}
23+
24+
type updateSecretRequest struct {
25+
// Value is the secret, base64-encoded (standard encoding). See
26+
// createSecretRequest.Value.
27+
Value string `json:"value"`
28+
}
29+
30+
// validateSecretName restricts secret names to non-empty alphanumeric and
31+
// underscore characters. This keeps names safe as keychain accounts and free of
32+
// the "|" StoreKey delimiter.
33+
func validateSecretName(name string) error {
34+
if name == "" {
35+
return fmt.Errorf("secret name is required")
36+
}
37+
if len(name) > 255 {
38+
return fmt.Errorf("secret name must be at most 255 characters")
39+
}
40+
for _, r := range name {
41+
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_') {
42+
return fmt.Errorf("secret name must contain only alphanumeric characters and underscores")
43+
}
44+
}
45+
return nil
46+
}
47+
48+
// decodeSecretValue decodes the base64-encoded value carried in create/update
49+
// requests and enforces the size limit on the decoded bytes (what is actually
50+
// stored), not on the inflated base64 representation.
51+
func decodeSecretValue(encoded string) (string, error) {
52+
if encoded == "" {
53+
return "", fmt.Errorf("secret value is required")
54+
}
55+
decoded, err := base64.StdEncoding.DecodeString(encoded)
56+
if err != nil {
57+
return "", fmt.Errorf("secret value must be valid base64")
58+
}
59+
if len(decoded) == 0 {
60+
return "", fmt.Errorf("secret value is required")
61+
}
62+
// Reject an all-whitespace value: it is almost always a fat-finger and
63+
// behaves like an empty secret. Internal/leading/trailing whitespace on an
64+
// otherwise non-blank value is preserved, since it can be significant.
65+
if strings.TrimSpace(string(decoded)) == "" {
66+
return "", fmt.Errorf("secret value must not be blank")
67+
}
68+
if len(decoded) > maxSecretValueBytes {
69+
return "", fmt.Errorf("secret value must be at most %d bytes", maxSecretValueBytes)
70+
}
71+
return string(decoded), nil
72+
}
73+
74+
// registerSecretRoutes wires the org-scoped secret CRUD endpoints. There is
75+
// deliberately no endpoint that returns a secret value; values are only ever
76+
// read internally through the SecretProvider chain.
77+
func (s *Server) registerSecretRoutes(router *gin.Engine, prefix string) {
78+
router.GET(prefix+"/organizations/:org_id/secrets", wrapHTTPWithPathValues(s.handleListSecrets(), "org_id"))
79+
router.POST(prefix+"/organizations/:org_id/secrets", wrapHTTPWithPathValues(s.handleCreateSecret(), "org_id"))
80+
router.PUT(prefix+"/organizations/:org_id/secrets/:name", wrapHTTPWithPathValues(s.handleUpdateSecret(), "org_id", "name"))
81+
router.DELETE(prefix+"/organizations/:org_id/secrets/:name", wrapHTTPWithPathValues(s.handleDeleteSecret(), "org_id", "name"))
82+
}
83+
84+
func (s *Server) handleListSecrets() http.HandlerFunc {
85+
return func(w http.ResponseWriter, r *http.Request) {
86+
orgID := strings.TrimSpace(r.PathValue("org_id"))
87+
if err := validateOrgID(orgID); err != nil {
88+
ReplyError(w, http.StatusBadRequest, err.Error())
89+
return
90+
}
91+
names, err := s.secretManager.List(orgID)
92+
if err != nil {
93+
ReplyError(w, http.StatusInternalServerError, err.Error())
94+
return
95+
}
96+
ReplyJSON(w, http.StatusOK, map[string]interface{}{"secrets": names})
97+
}
98+
}
99+
100+
func (s *Server) handleCreateSecret() http.HandlerFunc {
101+
return func(w http.ResponseWriter, r *http.Request) {
102+
orgID := strings.TrimSpace(r.PathValue("org_id"))
103+
if err := validateOrgID(orgID); err != nil {
104+
ReplyError(w, http.StatusBadRequest, err.Error())
105+
return
106+
}
107+
var req createSecretRequest
108+
if !decodeJSONBody(w, r, &req) {
109+
return
110+
}
111+
name := strings.TrimSpace(req.Name)
112+
if err := validateSecretName(name); err != nil {
113+
ReplyError(w, http.StatusUnprocessableEntity, err.Error())
114+
return
115+
}
116+
value, err := decodeSecretValue(req.Value)
117+
if err != nil {
118+
ReplyError(w, http.StatusUnprocessableEntity, err.Error())
119+
return
120+
}
121+
err = s.secretManager.Set(orgID, name, value)
122+
if errors.Is(err, secrets.ErrSecretExists) {
123+
ReplyError(w, http.StatusConflict, "secret already exists: "+name)
124+
return
125+
}
126+
if err != nil {
127+
ReplyError(w, http.StatusInternalServerError, err.Error())
128+
return
129+
}
130+
ReplyJSON(w, http.StatusCreated, map[string]interface{}{"name": name})
131+
}
132+
}
133+
134+
func (s *Server) handleUpdateSecret() http.HandlerFunc {
135+
return func(w http.ResponseWriter, r *http.Request) {
136+
orgID := strings.TrimSpace(r.PathValue("org_id"))
137+
if err := validateOrgID(orgID); err != nil {
138+
ReplyError(w, http.StatusBadRequest, err.Error())
139+
return
140+
}
141+
name := strings.TrimSpace(r.PathValue("name"))
142+
if err := validateSecretName(name); err != nil {
143+
ReplyError(w, http.StatusUnprocessableEntity, err.Error())
144+
return
145+
}
146+
var req updateSecretRequest
147+
if !decodeJSONBody(w, r, &req) {
148+
return
149+
}
150+
value, err := decodeSecretValue(req.Value)
151+
if err != nil {
152+
ReplyError(w, http.StatusUnprocessableEntity, err.Error())
153+
return
154+
}
155+
err = s.secretManager.Update(orgID, name, value)
156+
if errors.Is(err, secrets.ErrSecretNotFound) {
157+
ReplyError(w, http.StatusNotFound, "secret not found: "+name)
158+
return
159+
}
160+
if err != nil {
161+
ReplyError(w, http.StatusInternalServerError, err.Error())
162+
return
163+
}
164+
ReplyJSON(w, http.StatusOK, map[string]interface{}{"name": name})
165+
}
166+
}
167+
168+
func (s *Server) handleDeleteSecret() http.HandlerFunc {
169+
return func(w http.ResponseWriter, r *http.Request) {
170+
orgID := strings.TrimSpace(r.PathValue("org_id"))
171+
if err := validateOrgID(orgID); err != nil {
172+
ReplyError(w, http.StatusBadRequest, err.Error())
173+
return
174+
}
175+
name := strings.TrimSpace(r.PathValue("name"))
176+
if err := validateSecretName(name); err != nil {
177+
ReplyError(w, http.StatusUnprocessableEntity, err.Error())
178+
return
179+
}
180+
if !s.secretManager.Delete(orgID, name) {
181+
ReplyError(w, http.StatusNotFound, "secret not found: "+name)
182+
return
183+
}
184+
ReplyJSON(w, http.StatusOK, map[string]interface{}{
185+
"name": name,
186+
"deleted": true,
187+
})
188+
}
189+
}

0 commit comments

Comments
 (0)