Add workspace membership roles with serialized permission changes
This commit is contained in:
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user