From 53c4dbee8ed838f1fe710e83a320989cdb43cc0b Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 18:19:19 +0800 Subject: [PATCH] Add workspace membership roles with serialized permission changes --- internal/platform/http.go | 6 + internal/platform/workspaces.go | 239 ++++++++++++++++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 internal/platform/workspaces.go diff --git a/internal/platform/http.go b/internal/platform/http.go index 3269da6..8a468be 100644 --- a/internal/platform/http.go +++ b/internal/platform/http.go @@ -86,6 +86,8 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H switch { case errors.Is(err, sql.ErrNoRows): reject(w, 404, "not_found") + case errors.As(err, &pg) && (pg.Code == "22P02" || pg.Code == "23503" || pg.Code == "23514"): + reject(w, 400, "invalid_reference") case errors.As(err, &pg) && pg.Code == "23505": reject(w, 409, "already_exists") default: @@ -98,6 +100,10 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string) { s := &service{db: db} + mux.HandleFunc("/projects/{project}/workspaces", s.protected(callbacks, s.workspaces)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}", s.protected(callbacks, s.workspace)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/members", s.protected(callbacks, s.members)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/members/{account}", s.protected(callbacks, s.member)) mux.HandleFunc("/projects/{project}/profile", s.protected(callbacks, s.profile)) } diff --git a/internal/platform/workspaces.go b/internal/platform/workspaces.go new file mode 100644 index 0000000..b428f92 --- /dev/null +++ b/internal/platform/workspaces.go @@ -0,0 +1,239 @@ +package platform + +import ( + "context" + "database/sql" + "net/http" + "strings" +) + +func (s *service) workspaces(w http.ResponseWriter, r *http.Request, project, account string) error { + switch r.Method { + case http.MethodGet: + rows, err := s.db.QueryContext(r.Context(), ` +SELECT w.id::text,w.name,m.role,w.owner_id::text FROM workspaces w +JOIN workspace_members m ON (m.project,m.workspace_id)=(w.project,w.id) +WHERE m.project=$1 AND m.account_id=$2 ORDER BY lower(w.name),w.id LIMIT 200 +`, project, account) + if err != nil { + return err + } + defer rows.Close() + items := []response{} + for rows.Next() { + var id, name, role, owner string + if err := rows.Scan(&id, &name, &role, &owner); err != nil { + return err + } + items = append(items, response{"id": id, "name": name, "role": role, "owner_id": owner}) + } + if err := rows.Err(); err != nil { + return err + } + reply(w, 200, response{"items": items}) + case http.MethodPost: + var input struct { + Name string `json:"name"` + } + if !decode(w, r, &input) { + return nil + } + input.Name = strings.TrimSpace(input.Name) + if input.Name == "" || len(input.Name) > 160 { + reject(w, 400, "invalid_workspace") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + id, err := createWorkspace(r.Context(), tx, project, account, input.Name) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 201, response{"id": id, "name": input.Name, "role": "admin", "owner_id": account}) + default: + reject(w, 405, "method_not_allowed") + } + return nil +} + +func createWorkspace(ctx context.Context, tx *sql.Tx, project, account, name string) (string, error) { + var id string + err := tx.QueryRowContext(ctx, ` +INSERT INTO workspaces(project,name,owner_id) VALUES($1,$2,$3) RETURNING id::text +`, project, name, account).Scan(&id) + if err != nil { + return "", err + } + _, err = tx.ExecContext(ctx, ` +INSERT INTO workspace_members(project,workspace_id,account_id,role) VALUES($1,$2,$3,'admin') +`, project, id, account) + return id, err +} + +func lockWorkspace(ctx context.Context, tx *sql.Tx, project, workspace, account string) (string, string, error) { + var owner, role string + err := tx.QueryRowContext(ctx, ` +SELECT owner_id::text FROM workspaces WHERE project=$1 AND id=$2 FOR UPDATE +`, project, workspace).Scan(&owner) + if err != nil { + return "", "", err + } + // Read membership after the workspace lock, so concurrent revocation is visible. + err = tx.QueryRowContext(ctx, ` +SELECT role FROM workspace_members WHERE project=$1 AND workspace_id=$2 AND account_id=$3 +`, project, workspace, account).Scan(&role) + return owner, role, err +} + +func (s *service) workspace(w http.ResponseWriter, r *http.Request, project, account string) error { + workspace := r.PathValue("workspace") + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + owner, role, err := lockWorkspace(r.Context(), tx, project, workspace, account) + if err != nil { + return err + } + switch r.Method { + case http.MethodGet: + var name string + if err := tx.QueryRowContext(r.Context(), "SELECT name FROM workspaces WHERE project=$1 AND id=$2", project, workspace).Scan(&name); err != nil { + return err + } + reply(w, 200, response{"id": workspace, "name": name, "role": role, "owner_id": owner}) + return nil + case http.MethodPatch: + if role != "admin" { + reject(w, 403, "forbidden") + return nil + } + var input struct { + Name string `json:"name"` + } + if !decode(w, r, &input) { + return nil + } + input.Name = strings.TrimSpace(input.Name) + if input.Name == "" || len(input.Name) > 160 { + reject(w, 400, "invalid_workspace") + return nil + } + _, err = tx.ExecContext(r.Context(), "UPDATE workspaces SET name=$3,updated_at=now() WHERE project=$1 AND id=$2", project, workspace, input.Name) + case http.MethodDelete: + if owner != account { + reject(w, 403, "owner_required") + return nil + } + _, err = tx.ExecContext(r.Context(), "DELETE FROM workspaces WHERE project=$1 AND id=$2", project, workspace) + default: + reject(w, 405, "method_not_allowed") + return nil + } + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 204, nil) + return nil +} + +func (s *service) members(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodGet { + reject(w, 405, "method_not_allowed") + return nil + } + rows, err := s.db.QueryContext(r.Context(), ` +SELECT m.account_id::text,m.role,a.username,a.display_name FROM workspace_members m +JOIN project_accounts a ON (a.project,a.id)=(m.project,m.account_id) +WHERE m.project=$1 AND m.workspace_id=$2 AND EXISTS( + SELECT 1 FROM workspace_members caller WHERE caller.project=$1 AND caller.workspace_id=$2 AND caller.account_id=$3) +ORDER BY m.joined_at,m.account_id LIMIT 200 +`, project, r.PathValue("workspace"), account) + if err != nil { + return err + } + defer rows.Close() + items := []response{} + for rows.Next() { + var id, role, name string + var username *string + if err := rows.Scan(&id, &role, &username, &name); err != nil { + return err + } + items = append(items, response{"account_id": id, "role": role, "username": username, "display_name": name}) + } + if err := rows.Err(); err != nil { + return err + } + if len(items) == 0 { + return sql.ErrNoRows + } + reply(w, 200, response{"items": items}) + return nil +} + +func (s *service) member(w http.ResponseWriter, r *http.Request, project, account string) error { + workspace, target := r.PathValue("workspace"), r.PathValue("account") + if r.Method != http.MethodPatch && r.Method != http.MethodDelete { + reject(w, 405, "method_not_allowed") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + owner, role, err := lockWorkspace(r.Context(), tx, project, workspace, account) + if err != nil { + return err + } + if owner == target { + reject(w, 409, "owner_membership_required") + return nil + } + if role != "admin" && (r.Method != http.MethodDelete || target != account) { + reject(w, 403, "forbidden") + return nil + } + var result sql.Result + if r.Method == http.MethodPatch { + var input struct { + Role string `json:"role"` + } + if !decode(w, r, &input) { + return nil + } + if input.Role != "admin" && input.Role != "editor" && input.Role != "viewer" { + reject(w, 400, "invalid_role") + return nil + } + result, err = tx.ExecContext(r.Context(), "UPDATE workspace_members SET role=$4 WHERE project=$1 AND workspace_id=$2 AND account_id=$3", project, workspace, target, input.Role) + } else { + result, err = tx.ExecContext(r.Context(), "DELETE FROM workspace_members WHERE project=$1 AND workspace_id=$2 AND account_id=$3", project, workspace, target) + } + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return sql.ErrNoRows + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 204, nil) + return nil +}