Skip to content

Commit 6c7640a

Browse files
committed
feat: expose encrypt/decrypt endpoints on standard API for OSS compatibility
Move encrypt/decrypt logic into shared package-level functions in handler/encryption.go. Both AdminHandler and ProjectHandler delegate to these functions, eliminating code duplication. Register POST /api/v1/project/encrypt and POST /api/v1/project/decrypt on the standard API router so OSS Docker deployments (which don't run the admin binary) can use encryption features. Admin binary retains /admin/v1/project/encrypt and /admin/v1/project/decrypt for backward compatibility. E2E tests updated to call the standard API endpoints.
1 parent 108923f commit 6c7640a

6 files changed

Lines changed: 176 additions & 102 deletions

File tree

src/server/api/go/internal/bootstrap/container.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,12 @@ func BuildContainer() *do.Injector {
409409
), nil
410410
})
411411
do.Provide(inj, func(i *do.Injector) (*handler.ProjectHandler, error) {
412-
return handler.NewProjectHandler(do.MustInvoke[*gorm.DB](i)), nil
412+
return handler.NewProjectHandler(
413+
do.MustInvoke[*gorm.DB](i),
414+
do.MustInvoke[*redis.Client](i),
415+
do.MustInvoke[*blob.S3Deps](i),
416+
do.MustInvoke[repo.AssetReferenceRepo](i),
417+
), nil
413418
})
414419
return inj
415420
}

src/server/api/go/internal/modules/handler/admin.go

Lines changed: 2 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -240,105 +240,13 @@ func (h *AdminHandler) AnalyzeProjectMetrics(c *gin.Context) {
240240
// EncryptProject encrypts all existing S3 data for a project and enables encryption.
241241
// Requires project API key as Bearer auth (uses ProjectAuth middleware).
242242
func (h *AdminHandler) EncryptProject(c *gin.Context) {
243-
project, ok := c.MustGet("project").(*model.Project)
244-
if !ok {
245-
c.JSON(http.StatusBadRequest, serializer.ParamErr("", fmt.Errorf("project not found")))
246-
return
247-
}
248-
249-
userKEK := middleware.GetUserKEK(c)
250-
if userKEK == nil {
251-
c.JSON(http.StatusBadRequest, serializer.ParamErr("", fmt.Errorf("API key required to derive encryption key")))
252-
return
253-
}
254-
255-
// No early-return if EncryptionEnabled is already true — allow idempotent
256-
// retry so that partial failures (crash after flag set, before all objects
257-
// encrypted) can be recovered by re-calling this endpoint.
258-
// EncryptObject skips already-encrypted objects, and the DB UPDATE is a no-op
259-
// when the flag is already true.
260-
261-
// Set encryption_enabled = true FIRST for crash safety.
262-
// If we crash after this but before encrypting all objects, reads will use KEK
263-
// and unencrypted objects pass through decryption gracefully. Retry re-encrypts
264-
// remaining objects (EncryptObject is idempotent).
265-
if err := h.db.WithContext(c.Request.Context()).Model(&model.Project{}).
266-
Where("id = ?", project.ID).
267-
Update("encryption_enabled", true).Error; err != nil {
268-
c.JSON(http.StatusInternalServerError, serializer.DBErr("failed to update project", err))
269-
return
270-
}
271-
272-
// Invalidate cached project so subsequent requests see encryption_enabled = true
273-
middleware.InvalidateProjectAuthCache(h.rdb, project.SecretKeyHMAC)
274-
275-
// Enumerate all S3 keys for this project
276-
s3Keys, err := h.assetRefRepo.ListS3KeysByProject(c.Request.Context(), project.ID)
277-
if err != nil {
278-
c.JSON(http.StatusInternalServerError, serializer.DBErr("failed to list S3 keys", err))
279-
return
280-
}
281-
282-
// Encrypt each object (idempotent — skips already-encrypted objects)
283-
for _, key := range s3Keys {
284-
if err := h.s3.EncryptObject(c.Request.Context(), key, userKEK); err != nil {
285-
c.JSON(http.StatusInternalServerError, serializer.DBErr(fmt.Sprintf("failed to encrypt object %s", key), err))
286-
return
287-
}
288-
}
289-
290-
c.JSON(http.StatusOK, serializer.Response{Msg: "encryption enabled"})
243+
encryptProject(c, h.db, h.rdb, h.s3, h.assetRefRepo)
291244
}
292245

293246
// DecryptProject decrypts all existing S3 data for a project and disables encryption.
294247
// Requires project API key as Bearer auth (uses ProjectAuth middleware).
295248
func (h *AdminHandler) DecryptProject(c *gin.Context) {
296-
project, ok := c.MustGet("project").(*model.Project)
297-
if !ok {
298-
c.JSON(http.StatusBadRequest, serializer.ParamErr("", fmt.Errorf("project not found")))
299-
return
300-
}
301-
302-
userKEK := middleware.GetUserKEK(c)
303-
if userKEK == nil {
304-
c.JSON(http.StatusBadRequest, serializer.ParamErr("", fmt.Errorf("API key required to derive encryption key")))
305-
return
306-
}
307-
308-
if !project.EncryptionEnabled {
309-
c.JSON(http.StatusBadRequest, serializer.ParamErr("project encryption is not enabled", nil))
310-
return
311-
}
312-
313-
// Enumerate all S3 keys for this project
314-
s3Keys, err := h.assetRefRepo.ListS3KeysByProject(c.Request.Context(), project.ID)
315-
if err != nil {
316-
c.JSON(http.StatusInternalServerError, serializer.DBErr("failed to list S3 keys", err))
317-
return
318-
}
319-
320-
// Decrypt each object FIRST, then clear the flag (idempotent — skips already-decrypted).
321-
// If we crash mid-decrypt, flag stays true so reads use KEK, which works on
322-
// both encrypted and already-decrypted objects. Retry re-decrypts remaining.
323-
for _, key := range s3Keys {
324-
if err := h.s3.DecryptObject(c.Request.Context(), key, userKEK); err != nil {
325-
c.JSON(http.StatusInternalServerError, serializer.DBErr(fmt.Sprintf("failed to decrypt object %s", key), err))
326-
return
327-
}
328-
}
329-
330-
// Set encryption_enabled = false AFTER all objects are decrypted
331-
if err := h.db.WithContext(c.Request.Context()).Model(&model.Project{}).
332-
Where("id = ?", project.ID).
333-
Update("encryption_enabled", false).Error; err != nil {
334-
c.JSON(http.StatusInternalServerError, serializer.DBErr("failed to update project", err))
335-
return
336-
}
337-
338-
// Invalidate cached project so subsequent requests see encryption_enabled = false
339-
middleware.InvalidateProjectAuthCache(h.rdb, project.SecretKeyHMAC)
340-
341-
c.JSON(http.StatusOK, serializer.Response{Msg: "encryption disabled"})
249+
decryptProject(c, h.db, h.rdb, h.s3, h.assetRefRepo)
342250
}
343251

344252
// RotateProjectSecretKeyAdmin rotates the project API key (admin JWT auth).
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package handler
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
7+
"github.com/gin-gonic/gin"
8+
"github.com/redis/go-redis/v9"
9+
10+
"github.com/memodb-io/Acontext/internal/infra/blob"
11+
"github.com/memodb-io/Acontext/internal/middleware"
12+
"github.com/memodb-io/Acontext/internal/modules/model"
13+
"github.com/memodb-io/Acontext/internal/modules/repo"
14+
"github.com/memodb-io/Acontext/internal/modules/serializer"
15+
"gorm.io/gorm"
16+
)
17+
18+
// encryptProject is the shared implementation for enabling project encryption.
19+
// Both AdminHandler and ProjectHandler delegate to this function.
20+
func encryptProject(c *gin.Context, db *gorm.DB, rdb *redis.Client, s3 *blob.S3Deps, assetRefRepo repo.AssetReferenceRepo) {
21+
project, ok := c.MustGet("project").(*model.Project)
22+
if !ok {
23+
c.JSON(http.StatusBadRequest, serializer.ParamErr("", fmt.Errorf("project not found")))
24+
return
25+
}
26+
27+
userKEK := middleware.GetUserKEK(c)
28+
if userKEK == nil {
29+
c.JSON(http.StatusBadRequest, serializer.ParamErr("", fmt.Errorf("API key required to derive encryption key")))
30+
return
31+
}
32+
33+
// No early-return if EncryptionEnabled is already true — allow idempotent
34+
// retry so that partial failures (crash after flag set, before all objects
35+
// encrypted) can be recovered by re-calling this endpoint.
36+
// EncryptObject skips already-encrypted objects, and the DB UPDATE is a no-op
37+
// when the flag is already true.
38+
39+
// Set encryption_enabled = true FIRST for crash safety.
40+
// If we crash after this but before encrypting all objects, reads will use KEK
41+
// and unencrypted objects pass through decryption gracefully. Retry re-encrypts
42+
// remaining objects (EncryptObject is idempotent).
43+
if err := db.WithContext(c.Request.Context()).Model(&model.Project{}).
44+
Where("id = ?", project.ID).
45+
Update("encryption_enabled", true).Error; err != nil {
46+
c.JSON(http.StatusInternalServerError, serializer.DBErr("failed to update project", err))
47+
return
48+
}
49+
50+
// Invalidate cached project so subsequent requests see encryption_enabled = true
51+
middleware.InvalidateProjectAuthCache(rdb, project.SecretKeyHMAC)
52+
53+
// Enumerate all S3 keys for this project
54+
s3Keys, err := assetRefRepo.ListS3KeysByProject(c.Request.Context(), project.ID)
55+
if err != nil {
56+
c.JSON(http.StatusInternalServerError, serializer.DBErr("failed to list S3 keys", err))
57+
return
58+
}
59+
60+
// Encrypt each object (idempotent — skips already-encrypted objects)
61+
for _, key := range s3Keys {
62+
if err := s3.EncryptObject(c.Request.Context(), key, userKEK); err != nil {
63+
c.JSON(http.StatusInternalServerError, serializer.DBErr(fmt.Sprintf("failed to encrypt object %s", key), err))
64+
return
65+
}
66+
}
67+
68+
c.JSON(http.StatusOK, serializer.Response{Msg: "encryption enabled"})
69+
}
70+
71+
// decryptProject is the shared implementation for disabling project encryption.
72+
// Both AdminHandler and ProjectHandler delegate to this function.
73+
func decryptProject(c *gin.Context, db *gorm.DB, rdb *redis.Client, s3 *blob.S3Deps, assetRefRepo repo.AssetReferenceRepo) {
74+
project, ok := c.MustGet("project").(*model.Project)
75+
if !ok {
76+
c.JSON(http.StatusBadRequest, serializer.ParamErr("", fmt.Errorf("project not found")))
77+
return
78+
}
79+
80+
userKEK := middleware.GetUserKEK(c)
81+
if userKEK == nil {
82+
c.JSON(http.StatusBadRequest, serializer.ParamErr("", fmt.Errorf("API key required to derive encryption key")))
83+
return
84+
}
85+
86+
if !project.EncryptionEnabled {
87+
c.JSON(http.StatusBadRequest, serializer.ParamErr("project encryption is not enabled", nil))
88+
return
89+
}
90+
91+
// Enumerate all S3 keys for this project
92+
s3Keys, err := assetRefRepo.ListS3KeysByProject(c.Request.Context(), project.ID)
93+
if err != nil {
94+
c.JSON(http.StatusInternalServerError, serializer.DBErr("failed to list S3 keys", err))
95+
return
96+
}
97+
98+
// Decrypt each object FIRST, then clear the flag (idempotent — skips already-decrypted).
99+
// If we crash mid-decrypt, flag stays true so reads use KEK, which works on
100+
// both encrypted and already-decrypted objects. Retry re-decrypts remaining.
101+
for _, key := range s3Keys {
102+
if err := s3.DecryptObject(c.Request.Context(), key, userKEK); err != nil {
103+
c.JSON(http.StatusInternalServerError, serializer.DBErr(fmt.Sprintf("failed to decrypt object %s", key), err))
104+
return
105+
}
106+
}
107+
108+
// Set encryption_enabled = false AFTER all objects are decrypted
109+
if err := db.WithContext(c.Request.Context()).Model(&model.Project{}).
110+
Where("id = ?", project.ID).
111+
Update("encryption_enabled", false).Error; err != nil {
112+
c.JSON(http.StatusInternalServerError, serializer.DBErr("failed to update project", err))
113+
return
114+
}
115+
116+
// Invalidate cached project so subsequent requests see encryption_enabled = false
117+
middleware.InvalidateProjectAuthCache(rdb, project.SecretKeyHMAC)
118+
119+
c.JSON(http.StatusOK, serializer.Response{Msg: "encryption disabled"})
120+
}

src/server/api/go/internal/modules/handler/project.go

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,24 @@ import (
44
"net/http"
55

66
"github.com/gin-gonic/gin"
7+
"github.com/redis/go-redis/v9"
8+
9+
"github.com/memodb-io/Acontext/internal/infra/blob"
710
"github.com/memodb-io/Acontext/internal/modules/model"
11+
"github.com/memodb-io/Acontext/internal/modules/repo"
812
"github.com/memodb-io/Acontext/internal/modules/serializer"
913
"gorm.io/gorm"
1014
)
1115

1216
type ProjectHandler struct {
13-
db *gorm.DB
17+
db *gorm.DB
18+
rdb *redis.Client
19+
s3 *blob.S3Deps
20+
assetRefRepo repo.AssetReferenceRepo
1421
}
1522

16-
func NewProjectHandler(db *gorm.DB) *ProjectHandler {
17-
return &ProjectHandler{db: db}
23+
func NewProjectHandler(db *gorm.DB, rdb *redis.Client, s3 *blob.S3Deps, assetRefRepo repo.AssetReferenceRepo) *ProjectHandler {
24+
return &ProjectHandler{db: db, rdb: rdb, s3: s3, assetRefRepo: assetRefRepo}
1825
}
1926

2027
// GetConfigs godoc
@@ -147,3 +154,35 @@ func (h *ProjectHandler) PatchConfigs(c *gin.Context) {
147154
Msg: "ok",
148155
})
149156
}
157+
158+
// EncryptProject encrypts all existing S3 data for a project and enables encryption.
159+
// Requires project API key as Bearer auth (uses ProjectAuth middleware).
160+
//
161+
// @Summary Enable project encryption
162+
// @Description Encrypts all existing S3 data for the project and enables encryption for future writes.
163+
// @Tags Project
164+
// @Produce json
165+
// @Security BearerAuth
166+
// @Success 200 {object} serializer.Response
167+
// @Failure 400 {object} serializer.Response
168+
// @Failure 500 {object} serializer.Response
169+
// @Router /project/encrypt [post]
170+
func (h *ProjectHandler) EncryptProject(c *gin.Context) {
171+
encryptProject(c, h.db, h.rdb, h.s3, h.assetRefRepo)
172+
}
173+
174+
// DecryptProject decrypts all existing S3 data for a project and disables encryption.
175+
// Requires project API key as Bearer auth (uses ProjectAuth middleware).
176+
//
177+
// @Summary Disable project encryption
178+
// @Description Decrypts all existing S3 data for the project and disables encryption for future writes.
179+
// @Tags Project
180+
// @Produce json
181+
// @Security BearerAuth
182+
// @Success 200 {object} serializer.Response
183+
// @Failure 400 {object} serializer.Response
184+
// @Failure 500 {object} serializer.Response
185+
// @Router /project/decrypt [post]
186+
func (h *ProjectHandler) DecryptProject(c *gin.Context) {
187+
decryptProject(c, h.db, h.rdb, h.s3, h.assetRefRepo)
188+
}

src/server/api/go/internal/router/router.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,8 @@ func NewRouter(d RouterDeps) *gin.Engine {
158158
{
159159
project.GET("/configs", d.ProjectHandler.GetConfigs)
160160
project.PATCH("/configs", d.ProjectHandler.PatchConfigs)
161+
project.POST("/encrypt", d.ProjectHandler.EncryptProject)
162+
project.POST("/decrypt", d.ProjectHandler.DecryptProject)
161163
}
162164

163165
learningSpaces := v1.Group("/learning_spaces")

src/server/tests/e2e/test_encryption.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,9 +207,9 @@ async def enable_encryption(
207207
client: httpx.AsyncClient,
208208
bearer_token: str,
209209
) -> httpx.Response:
210-
"""Enable encryption on a project via admin endpoint."""
210+
"""Enable encryption on a project via standard API endpoint."""
211211
return await client.post(
212-
f"{ADMIN_URL}/admin/v1/project/encrypt",
212+
f"{API_URL}/api/v1/project/encrypt",
213213
headers={"Authorization": f"Bearer {bearer_token}"},
214214
)
215215

@@ -218,9 +218,9 @@ async def disable_encryption(
218218
client: httpx.AsyncClient,
219219
bearer_token: str,
220220
) -> httpx.Response:
221-
"""Disable encryption on a project via admin endpoint."""
221+
"""Disable encryption on a project via standard API endpoint."""
222222
return await client.post(
223-
f"{ADMIN_URL}/admin/v1/project/decrypt",
223+
f"{API_URL}/api/v1/project/decrypt",
224224
headers={"Authorization": f"Bearer {bearer_token}"},
225225
)
226226

0 commit comments

Comments
 (0)