mirror of
https://github.com/luxfi/kms.git
synced 2026-07-27 05:54:18 +00:00
kms: web-UI projects + environments (Tier 2)
ZapDB-backed projects (kms/projects/{id} + by-org index), default envs
dev/staging/prod on create, inline environment CRUD. Endpoints:
GET/POST /v1/projects, GET/DELETE /v1/projects/{id}
POST/PATCH/DELETE /v1/projects/{id}/environments[/{envId}]
GET /v1/organization/{orgId}/my-workspaces
GET /v1/projects/{id}/environment-folder-tree (secrets-nav scaffold)
Test TestProjectFlow: create→list→get→add/patch/delete env→tree→unauth. Green.
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
// Tier 2 — projects + environments (the secrets-manager shell).
|
||||
//
|
||||
// GET /v1/projects {projects: [...]}
|
||||
// POST /v1/projects {project} (default envs dev/staging/prod)
|
||||
// GET /v1/projects/{id} {project}
|
||||
// DELETE /v1/projects/{id}
|
||||
// POST /v1/projects/{id}/environments {environment}
|
||||
// PATCH/DELETE /v1/projects/{id}/environments/{envId}
|
||||
// GET /v1/organization/{orgId}/my-workspaces {workspaces: [...]}
|
||||
// GET /v1/projects/{id}/environment-folder-tree (secrets-nav scaffold)
|
||||
//
|
||||
// Projects/envs persist as JSON-KV in ZapDB (kms/projects/{id} +
|
||||
// kms/projects/by-org/{orgId}/{id}). Environments live inline on the project.
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
badger "github.com/luxfi/zapdb"
|
||||
)
|
||||
|
||||
type projectEnv struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
|
||||
type project struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description string `json:"description"`
|
||||
OrgID string `json:"orgId"`
|
||||
Environments []projectEnv `json:"environments"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func projectKey(id string) []byte { return []byte("kms/projects/" + id) }
|
||||
func projectOrgIdx(orgID, id string) []byte {
|
||||
return []byte("kms/projects/by-org/" + orgID + "/" + id)
|
||||
}
|
||||
func projectOrgPrefix(orgID string) []byte { return []byte("kms/projects/by-org/" + orgID + "/") }
|
||||
|
||||
func defaultEnvs() []projectEnv {
|
||||
return []projectEnv{
|
||||
{ID: newID(), Name: "Development", Slug: "dev"},
|
||||
{ID: newID(), Name: "Staging", Slug: "staging"},
|
||||
{ID: newID(), Name: "Production", Slug: "prod"},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *webStore) CreateProject(orgID, name string) (*project, error) {
|
||||
now := time.Now().UTC()
|
||||
p := &project{
|
||||
ID: newID(), Name: name, Slug: slugify(name), OrgID: orgID,
|
||||
Environments: defaultEnvs(), CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
if err := s.putJSON(projectKey(p.ID), p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.Update(func(txn *badger.Txn) error {
|
||||
return txn.Set(projectOrgIdx(orgID, p.ID), []byte(p.ID))
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *webStore) GetProject(id string) (*project, error) {
|
||||
var p project
|
||||
if err := s.getJSON(projectKey(id), &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (s *webStore) SaveProject(p *project) error {
|
||||
p.UpdatedAt = time.Now().UTC()
|
||||
return s.putJSON(projectKey(p.ID), p)
|
||||
}
|
||||
|
||||
func (s *webStore) ProjectsForOrg(orgID string) ([]*project, error) {
|
||||
var ids []string
|
||||
err := s.db.View(func(txn *badger.Txn) error {
|
||||
opts := badger.DefaultIteratorOptions
|
||||
opts.PrefetchValues = false
|
||||
opts.Prefix = projectOrgPrefix(orgID)
|
||||
it := txn.NewIterator(opts)
|
||||
defer it.Close()
|
||||
pfx := projectOrgPrefix(orgID)
|
||||
for it.Rewind(); it.Valid(); it.Next() {
|
||||
k := it.Item().Key()
|
||||
ids = append(ids, string(k[len(pfx):]))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]*project, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if p, err := s.GetProject(id); err == nil {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *webStore) DeleteProject(id string) error {
|
||||
p, err := s.GetProject(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.db.Update(func(txn *badger.Txn) error {
|
||||
_ = txn.Delete(projectKey(id))
|
||||
return txn.Delete(projectOrgIdx(p.OrgID, id))
|
||||
})
|
||||
}
|
||||
|
||||
// projectJSON renders the SPA Project shape (fields the UI reads; the rest get
|
||||
// sensible defaults so deserialization never trips).
|
||||
func projectJSON(p *project) map[string]any {
|
||||
envs := make([]map[string]any, 0, len(p.Environments))
|
||||
for _, e := range p.Environments {
|
||||
envs = append(envs, map[string]any{"id": e.ID, "name": e.Name, "slug": e.Slug})
|
||||
}
|
||||
return map[string]any{
|
||||
"__v": 0, "id": p.ID, "name": p.Name, "slug": p.Slug, "type": "secret-manager",
|
||||
"description": p.Description, "orgId": p.OrgID, "version": 3, "upgradeStatus": nil,
|
||||
"autoCapitalization": false, "environments": envs, "pitVersionLimit": 10,
|
||||
"auditLogsRetentionDays": 0, "hasDeleteProtection": false, "secretSharing": true,
|
||||
"showSnapshotsLegacy": false, "secretDetectionIgnoreValues": []string{},
|
||||
"createdAt": p.CreatedAt, "updatedAt": p.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func registerProjectAPI(mux *http.ServeMux, db *badger.DB) {
|
||||
st := newWebStore(db)
|
||||
auth := newWebAuth(webAuthSecret(db))
|
||||
|
||||
authed := func(w http.ResponseWriter, r *http.Request) *webClaims {
|
||||
cl := auth.fromRequest(r)
|
||||
if cl == nil {
|
||||
writeJSON(w, http.StatusUnauthorized, msg("unauthorized"))
|
||||
}
|
||||
return cl
|
||||
}
|
||||
|
||||
// GET /v1/projects — projects in the caller's selected org.
|
||||
mux.HandleFunc("GET /v1/projects", func(w http.ResponseWriter, r *http.Request) {
|
||||
cl := authed(w, r)
|
||||
if cl == nil {
|
||||
return
|
||||
}
|
||||
ps, _ := st.ProjectsForOrg(cl.OrgID)
|
||||
out := make([]any, 0, len(ps))
|
||||
for _, p := range ps {
|
||||
out = append(out, projectJSON(p))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"projects": out})
|
||||
})
|
||||
|
||||
// POST /v1/projects — create a project (with default environments).
|
||||
mux.HandleFunc("POST /v1/projects", func(w http.ResponseWriter, r *http.Request) {
|
||||
cl := authed(w, r)
|
||||
if cl == nil {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ProjectName string `json:"projectName"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
_ = decode(w, r, &req)
|
||||
name := req.ProjectName
|
||||
if name == "" {
|
||||
name = req.Name
|
||||
}
|
||||
if name == "" {
|
||||
writeJSON(w, http.StatusBadRequest, msg("project name required"))
|
||||
return
|
||||
}
|
||||
p, err := st.CreateProject(cl.OrgID, name)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, msg(err.Error()))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"project": projectJSON(p)})
|
||||
})
|
||||
|
||||
// GET /v1/projects/{id}
|
||||
mux.HandleFunc("GET /v1/projects/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
if authed(w, r) == nil {
|
||||
return
|
||||
}
|
||||
p, err := st.GetProject(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, msg("project not found"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"project": projectJSON(p)})
|
||||
})
|
||||
|
||||
// DELETE /v1/projects/{id}
|
||||
mux.HandleFunc("DELETE /v1/projects/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
if authed(w, r) == nil {
|
||||
return
|
||||
}
|
||||
if err := st.DeleteProject(r.PathValue("id")); err != nil {
|
||||
writeJSON(w, http.StatusNotFound, msg("project not found"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, msg("deleted"))
|
||||
})
|
||||
|
||||
// POST /v1/projects/{id}/environments
|
||||
mux.HandleFunc("POST /v1/projects/{id}/environments", func(w http.ResponseWriter, r *http.Request) {
|
||||
if authed(w, r) == nil {
|
||||
return
|
||||
}
|
||||
p, err := st.GetProject(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, msg("project not found"))
|
||||
return
|
||||
}
|
||||
var req struct{ Name, Slug string }
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.Slug == "" {
|
||||
req.Slug = slugify(req.Name)
|
||||
}
|
||||
env := projectEnv{ID: newID(), Name: req.Name, Slug: req.Slug}
|
||||
p.Environments = append(p.Environments, env)
|
||||
_ = st.SaveProject(p)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"environment": map[string]any{"id": env.ID, "name": env.Name, "slug": env.Slug}})
|
||||
})
|
||||
|
||||
// PATCH /v1/projects/{id}/environments/{envId}
|
||||
mux.HandleFunc("PATCH /v1/projects/{id}/environments/{envId}", func(w http.ResponseWriter, r *http.Request) {
|
||||
if authed(w, r) == nil {
|
||||
return
|
||||
}
|
||||
p, err := st.GetProject(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, msg("project not found"))
|
||||
return
|
||||
}
|
||||
var req struct{ Name, Slug string }
|
||||
_ = decode(w, r, &req)
|
||||
eid := r.PathValue("envId")
|
||||
for i := range p.Environments {
|
||||
if p.Environments[i].ID == eid {
|
||||
if req.Name != "" {
|
||||
p.Environments[i].Name = req.Name
|
||||
}
|
||||
if req.Slug != "" {
|
||||
p.Environments[i].Slug = req.Slug
|
||||
}
|
||||
_ = st.SaveProject(p)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"environment": map[string]any{"id": p.Environments[i].ID, "name": p.Environments[i].Name, "slug": p.Environments[i].Slug}})
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusNotFound, msg("environment not found"))
|
||||
})
|
||||
|
||||
// DELETE /v1/projects/{id}/environments/{envId}
|
||||
mux.HandleFunc("DELETE /v1/projects/{id}/environments/{envId}", func(w http.ResponseWriter, r *http.Request) {
|
||||
if authed(w, r) == nil {
|
||||
return
|
||||
}
|
||||
p, err := st.GetProject(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, msg("project not found"))
|
||||
return
|
||||
}
|
||||
eid := r.PathValue("envId")
|
||||
kept := p.Environments[:0]
|
||||
for _, e := range p.Environments {
|
||||
if e.ID != eid {
|
||||
kept = append(kept, e)
|
||||
}
|
||||
}
|
||||
p.Environments = kept
|
||||
_ = st.SaveProject(p)
|
||||
writeJSON(w, http.StatusOK, msg("deleted"))
|
||||
})
|
||||
|
||||
// GET /v1/organization/{orgId}/my-workspaces
|
||||
mux.HandleFunc("GET /v1/organization/{orgId}/my-workspaces", func(w http.ResponseWriter, r *http.Request) {
|
||||
if authed(w, r) == nil {
|
||||
return
|
||||
}
|
||||
ps, _ := st.ProjectsForOrg(r.PathValue("orgId"))
|
||||
out := make([]any, 0, len(ps))
|
||||
for _, p := range ps {
|
||||
out = append(out, projectJSON(p))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"workspaces": out})
|
||||
})
|
||||
|
||||
// GET /v1/projects/{id}/environment-folder-tree — secrets-nav scaffold:
|
||||
// each environment with its (root) folder list. Folders fill in with Tier 3.
|
||||
mux.HandleFunc("GET /v1/projects/{id}/environment-folder-tree", func(w http.ResponseWriter, r *http.Request) {
|
||||
if authed(w, r) == nil {
|
||||
return
|
||||
}
|
||||
p, err := st.GetProject(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, msg("project not found"))
|
||||
return
|
||||
}
|
||||
tree := map[string]any{}
|
||||
for _, e := range p.Environments {
|
||||
tree[e.Slug] = map[string]any{
|
||||
"environment": map[string]any{"id": e.ID, "name": e.Name, "slug": e.Slug},
|
||||
"folders": []any{},
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, tree)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
badger "github.com/luxfi/zapdb"
|
||||
)
|
||||
|
||||
// projTestServer wires core + project APIs and returns a logged-in,
|
||||
// org-scoped session token to drive the project flow.
|
||||
func projTestServer(t *testing.T) (*httptest.Server, string) {
|
||||
t.Helper()
|
||||
db, err := badger.Open(badger.DefaultOptions("").WithInMemory(true))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
mux := http.NewServeMux()
|
||||
registerCoreAPI(mux, db, "")
|
||||
registerProjectAPI(mux, db)
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
if code, _ := do(t, "POST", srv.URL+"/v1/admin/signup", "", map[string]any{
|
||||
"email": "z@lux.network", "password": "S3cret-pw!", "organizationName": "Lux",
|
||||
}); code != 200 {
|
||||
t.Fatalf("signup: %d", code)
|
||||
}
|
||||
_, login := do(t, "POST", srv.URL+"/v1/auth/login", "", map[string]any{"email": "z@lux.network", "password": "S3cret-pw!"})
|
||||
_, sel := do(t, "POST", srv.URL+"/v1/auth/select-organization", login["accessToken"].(string), map[string]any{})
|
||||
return srv, sel["token"].(string)
|
||||
}
|
||||
|
||||
// TestProjectFlow: create project (default envs) → list → get → add/patch/delete
|
||||
// env → my-workspaces → env-folder-tree. Tier 2.
|
||||
func TestProjectFlow(t *testing.T) {
|
||||
srv, tok := projTestServer(t)
|
||||
|
||||
// create
|
||||
code, cp := do(t, "POST", srv.URL+"/v1/projects", tok, map[string]any{"projectName": "App Secrets"})
|
||||
if code != 200 {
|
||||
t.Fatalf("create project: %d %v", code, cp)
|
||||
}
|
||||
p := cp["project"].(map[string]any)
|
||||
pid := p["id"].(string)
|
||||
if p["slug"] != "app-secrets" {
|
||||
t.Fatalf("slug: %v", p["slug"])
|
||||
}
|
||||
if envs := p["environments"].([]any); len(envs) != 3 {
|
||||
t.Fatalf("expected 3 default envs, got %d", len(envs))
|
||||
}
|
||||
|
||||
// list
|
||||
_, lp := do(t, "GET", srv.URL+"/v1/projects", tok, nil)
|
||||
if len(lp["projects"].([]any)) != 1 {
|
||||
t.Fatalf("list projects != 1")
|
||||
}
|
||||
|
||||
// get
|
||||
if code, _ := do(t, "GET", srv.URL+"/v1/projects/"+pid, tok, nil); code != 200 {
|
||||
t.Fatalf("get project: %d", code)
|
||||
}
|
||||
|
||||
// add env
|
||||
code, ae := do(t, "POST", srv.URL+"/v1/projects/"+pid+"/environments", tok, map[string]any{"name": "QA", "slug": "qa"})
|
||||
if code != 200 || ae["environment"].(map[string]any)["slug"] != "qa" {
|
||||
t.Fatalf("add env: %d %v", code, ae)
|
||||
}
|
||||
envID := ae["environment"].(map[string]any)["id"].(string)
|
||||
|
||||
// project now has 4 envs
|
||||
_, gp := do(t, "GET", srv.URL+"/v1/projects/"+pid, tok, nil)
|
||||
if len(gp["project"].(map[string]any)["environments"].([]any)) != 4 {
|
||||
t.Fatalf("expected 4 envs after add")
|
||||
}
|
||||
|
||||
// patch env
|
||||
if code, _ := do(t, "PATCH", srv.URL+"/v1/projects/"+pid+"/environments/"+envID, tok, map[string]any{"name": "Quality"}); code != 200 {
|
||||
t.Fatalf("patch env failed")
|
||||
}
|
||||
|
||||
// delete env → back to 3
|
||||
if code, _ := do(t, "DELETE", srv.URL+"/v1/projects/"+pid+"/environments/"+envID, tok, nil); code != 200 {
|
||||
t.Fatalf("delete env failed")
|
||||
}
|
||||
_, gp2 := do(t, "GET", srv.URL+"/v1/projects/"+pid, tok, nil)
|
||||
if len(gp2["project"].(map[string]any)["environments"].([]any)) != 3 {
|
||||
t.Fatalf("expected 3 envs after delete")
|
||||
}
|
||||
|
||||
// env-folder-tree has an entry per env slug
|
||||
_, tree := do(t, "GET", srv.URL+"/v1/projects/"+pid+"/environment-folder-tree", tok, nil)
|
||||
if _, ok := tree["dev"]; !ok {
|
||||
t.Fatalf("env-folder-tree missing dev env: %v", tree)
|
||||
}
|
||||
|
||||
// unauth is rejected
|
||||
if code, _ := do(t, "GET", srv.URL+"/v1/projects", "", nil); code != 401 {
|
||||
t.Fatalf("expected 401 unauth, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -178,6 +178,8 @@ func main() {
|
||||
// Postgres/Redis/Node/IAM. See api_core.go + webauth.go. The /v1/admin/config
|
||||
// `initialized` flag now reflects whether any user exists (first-run gate).
|
||||
registerCoreAPI(mux, db, defaultOrgSlug)
|
||||
// Tier 2 — projects + environments (the secrets-manager shell). See api_projects.go.
|
||||
registerProjectAPI(mux, db)
|
||||
|
||||
// Machine identity auth via IAM.
|
||||
mux.HandleFunc("POST /v1/kms/auth/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Reference in New Issue
Block a user