Skip to content

Commit 237df63

Browse files
authored
[Site Admin] Promote collaborator and remove owner #5695
ref DEV-3536
2 parents 98d5a0b + ed8da23 commit 237df63

17 files changed

Lines changed: 754 additions & 15 deletions

File tree

.vettedpositions

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,7 @@
330330
/pkg/siteadmin/transport/handler_apps_list.go:81:37: requestcontext
331331
/pkg/siteadmin/transport/handler_collaborator_add.go:47:67: requestcontext
332332
/pkg/siteadmin/transport/handler_collaborator_add.go:64:49: requestcontext
333+
/pkg/siteadmin/transport/handler_collaborator_promote.go:39:53: requestcontext
333334
/pkg/siteadmin/transport/handler_collaborator_remove.go:38:41: requestcontext
334335
/pkg/siteadmin/transport/handler_collaborators_list.go:38:52: requestcontext
335336
/pkg/siteadmin/transport/handler_messaging_usage.go:78:44: requestcontext
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
-- +migrate Up
2+
3+
ALTER TABLE _portal_app_collaborator ADD COLUMN updated_at timestamp with time zone;
4+
UPDATE _portal_app_collaborator SET updated_at = created_at;
5+
ALTER TABLE _portal_app_collaborator ALTER COLUMN updated_at SET NOT NULL;
6+
7+
-- +migrate Down
8+
9+
ALTER TABLE _portal_app_collaborator DROP COLUMN updated_at;

cmd/portal/internal/collaborator.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,12 +177,14 @@ func insertCollaborator(ctx context.Context, tx *sql.Tx, appID string, userID st
177177
"app_id",
178178
"user_id",
179179
"created_at",
180+
"updated_at",
180181
"role",
181182
).Values(
182183
id,
183184
appID,
184185
userID,
185186
now,
187+
now,
186188
role,
187189
)
188190

@@ -200,8 +202,10 @@ func insertCollaborator(ctx context.Context, tx *sql.Tx, appID string, userID st
200202
}
201203

202204
func updateCollaboratorRole(ctx context.Context, tx *sql.Tx, id string, role model.CollaboratorRole) error {
205+
now := time.Now().UTC()
203206
builder := newSQLBuilder().Update(pq.QuoteIdentifier("_portal_app_collaborator")).
204207
Set("role", role).
208+
Set("updated_at", now).
205209
Where("id = ?", id)
206210

207211
query, args, err := builder.ToSql()

docs/api/siteadmin-api.yaml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,33 @@ paths:
290290
"404":
291291
$ref: "#/components/responses/NotFound"
292292

293+
/api/v1/apps/{app_id}/collaborators/{collaborator_id}/promote:
294+
post:
295+
operationId: promoteCollaboratorToOwner
296+
summary: Promote a collaborator to owner, demoting the current owner to editor
297+
parameters:
298+
- name: app_id
299+
in: path
300+
required: true
301+
schema:
302+
type: string
303+
- name: collaborator_id
304+
in: path
305+
required: true
306+
schema:
307+
type: string
308+
responses:
309+
"200":
310+
description: The promoted collaborator
311+
content:
312+
application/json:
313+
schema:
314+
$ref: "#/components/schemas/Collaborator"
315+
"403":
316+
$ref: "#/components/responses/Forbidden"
317+
"404":
318+
$ref: "#/components/responses/NotFound"
319+
293320
/api/v1/apps/{app_id}:
294321
get:
295322
operationId: getApp
Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
# 07 — Promote Collaborator to Owner
2+
3+
## Goal / Scope
4+
5+
Implement `POST /api/v1/apps/{app_id}/collaborators/{collaborator_id}/promote`.
6+
7+
The target collaborator becomes owner; the current owner is demoted to editor. Both role changes happen in a single database transaction, with `updated_at` stamped on each updated row. Email resolution happens outside the transaction (Admin API call).
8+
9+
No request body. Returns the promoted collaborator as a `Collaborator` object.
10+
11+
---
12+
13+
## Schema Change
14+
15+
Add `updated_at` column to `_portal_app_collaborator`. Initial value is copied from `created_at`.
16+
17+
Migration file: `cmd/portal/cmd/cmddatabase/migrations/portal/20260429120000-add_collaborator_updated_at.sql`
18+
19+
```sql
20+
-- +migrate Up
21+
22+
ALTER TABLE _portal_app_collaborator ADD COLUMN updated_at timestamp with time zone;
23+
UPDATE _portal_app_collaborator SET updated_at = created_at;
24+
ALTER TABLE _portal_app_collaborator ALTER COLUMN updated_at SET NOT NULL;
25+
26+
-- +migrate Down
27+
28+
ALTER TABLE _portal_app_collaborator DROP COLUMN updated_at;
29+
```
30+
31+
---
32+
33+
## Existing Code Audit
34+
35+
No model change. INSERT paths set `updated_at = created_at` using the existing `CreatedAt` field already on the struct (or the `now` variable already in scope). UPDATE paths stamp `now`.
36+
37+
| File | Operation | Change |
38+
|---|---|---|
39+
| `pkg/portal/service/collaborator.go` `CreateCollaborator` | INSERT | Add `"updated_at"` column with value `c.CreatedAt` |
40+
| `pkg/siteadmin/service/collaborator.go` `CreateCollaborator` | INSERT | Add `"updated_at"` column with value `c.CreatedAt` |
41+
| `cmd/portal/internal/collaborator.go` `insertCollaborator` | INSERT | Add `"updated_at"` column with value `now` (already in scope) |
42+
| `cmd/portal/internal/collaborator.go` `updateCollaboratorRole` | UPDATE | Add `Set("updated_at", time.Now().UTC())` |
43+
44+
`model.Collaborator`, `NewCollaborator`, `selectCollaborator`, and `scanCollaborator` are **not changed**.
45+
46+
### `pkg/portal/service/collaborator.go``CreateCollaborator`
47+
48+
```go
49+
Columns("id", "app_id", "user_id", "created_at", "updated_at", "role").
50+
Values(c.ID, c.AppID, c.UserID, c.CreatedAt, c.CreatedAt, c.Role)
51+
```
52+
53+
### `pkg/siteadmin/service/collaborator.go``CreateCollaborator`
54+
55+
```go
56+
s.SQLBuilder.
57+
Insert(s.SQLBuilder.TableName("_portal_app_collaborator")).
58+
Columns("id", "app_id", "user_id", "created_at", "updated_at", "role").
59+
Values(c.ID, c.AppID, c.UserID, c.CreatedAt, c.CreatedAt, c.Role)
60+
```
61+
62+
### `cmd/portal/internal/collaborator.go``insertCollaborator` + `updateCollaboratorRole`
63+
64+
`insertCollaborator` (`now` already declared in the function):
65+
```go
66+
Columns("id", "app_id", "user_id", "created_at", "updated_at", "role").
67+
Values(id, appID, userID, now, now, role)
68+
```
69+
70+
`updateCollaboratorRole`:
71+
```go
72+
func updateCollaboratorRole(ctx context.Context, tx *sql.Tx, id string, role model.CollaboratorRole) error {
73+
now := time.Now().UTC()
74+
builder := newSQLBuilder().Update(pq.QuoteIdentifier("_portal_app_collaborator")).
75+
Set("role", role).
76+
Set("updated_at", now).
77+
Where("id = ?", id)
78+
...
79+
}
80+
```
81+
82+
---
83+
84+
## New Service Methods
85+
86+
### `pkg/siteadmin/service/collaborator.go`
87+
88+
**New error:**
89+
```go
90+
var ErrCollaboratorAlreadyOwner = apierrors.AlreadyExists.WithReason("CollaboratorAlreadyOwner").New("collaborator is already the owner")
91+
```
92+
93+
**`UpdateCollaborator` added to `CollaboratorServiceStore` interface:**
94+
```go
95+
UpdateCollaborator(ctx context.Context, c *model.Collaborator) error
96+
```
97+
98+
**`CollaboratorStore.UpdateCollaborator` implementation:**
99+
```go
100+
func (s *CollaboratorStore) UpdateCollaborator(ctx context.Context, c *model.Collaborator) error {
101+
now := s.Clock.NowUTC()
102+
_, err := s.SQLExecutor.ExecWith(ctx, s.SQLBuilder.
103+
Update(s.SQLBuilder.TableName("_portal_app_collaborator")).
104+
Set("role", c.Role).
105+
Set("updated_at", now).
106+
Where("id = ?", c.ID),
107+
)
108+
return err
109+
}
110+
```
111+
112+
**`CollaboratorService.PromoteCollaborator`:**
113+
```go
114+
func (s *CollaboratorService) PromoteCollaborator(ctx context.Context, appID string, collaboratorID string) (*siteadmin.Collaborator, error) {
115+
var promoted *model.Collaborator
116+
err := s.GlobalDatabase.WithTx(ctx, func(ctx context.Context) error {
117+
target, err := s.Store.GetCollaborator(ctx, collaboratorID)
118+
if err != nil {
119+
return err
120+
}
121+
if target.AppID != appID {
122+
return portalservice.ErrCollaboratorNotFound
123+
}
124+
if target.Role == model.CollaboratorRoleOwner {
125+
return ErrCollaboratorAlreadyOwner
126+
}
127+
128+
all, err := s.Store.ListCollaborators(ctx, appID)
129+
if err != nil {
130+
return err
131+
}
132+
var currentOwner *model.Collaborator
133+
for _, c := range all {
134+
if c.Role == model.CollaboratorRoleOwner {
135+
currentOwner = c
136+
break
137+
}
138+
}
139+
140+
target.Role = model.CollaboratorRoleOwner
141+
if err := s.Store.UpdateCollaborator(ctx, target); err != nil {
142+
return err
143+
}
144+
if currentOwner != nil {
145+
currentOwner.Role = model.CollaboratorRoleEditor
146+
if err := s.Store.UpdateCollaborator(ctx, currentOwner); err != nil {
147+
return err
148+
}
149+
}
150+
151+
promoted = target
152+
return nil
153+
})
154+
if err != nil {
155+
return nil, err
156+
}
157+
158+
emailMap, err := s.AdminAPI.ResolveUserEmails(ctx, []string{promoted.UserID})
159+
if err != nil {
160+
return nil, err
161+
}
162+
163+
return &siteadmin.Collaborator{
164+
Id: promoted.ID,
165+
AppId: promoted.AppID,
166+
UserId: promoted.UserID,
167+
UserEmail: emailMap[promoted.UserID],
168+
Role: siteadmin.CollaboratorRole(promoted.Role),
169+
CreatedAt: promoted.CreatedAt,
170+
}, nil
171+
}
172+
```
173+
174+
---
175+
176+
## Transport Handler
177+
178+
`pkg/siteadmin/transport/handler_collaborator_promote.go` — replace stub:
179+
180+
```go
181+
package transport
182+
183+
import (
184+
"context"
185+
"net/http"
186+
187+
"github.com/authgear/authgear-server/pkg/api/siteadmin"
188+
"github.com/authgear/authgear-server/pkg/util/httproute"
189+
)
190+
191+
func ConfigureCollaboratorPromoteRoute(route httproute.Route) httproute.Route {
192+
return route.WithMethods("OPTIONS", "POST").
193+
WithPathPattern("/api/v1/apps/:appID/collaborators/:collaboratorID/promote")
194+
}
195+
196+
type CollaboratorPromoteService interface {
197+
PromoteCollaborator(ctx context.Context, appID string, collaboratorID string) (*siteadmin.Collaborator, error)
198+
}
199+
200+
type CollaboratorPromoteHandler struct {
201+
Service CollaboratorPromoteService
202+
}
203+
204+
type CollaboratorPromoteParams struct {
205+
AppID string
206+
CollaboratorID string
207+
}
208+
209+
func parseCollaboratorPromoteParams(r *http.Request) CollaboratorPromoteParams {
210+
return CollaboratorPromoteParams{
211+
AppID: httproute.GetParam(r, "appID"),
212+
CollaboratorID: httproute.GetParam(r, "collaboratorID"),
213+
}
214+
}
215+
216+
func (h *CollaboratorPromoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
217+
params := parseCollaboratorPromoteParams(r)
218+
219+
collaborator, err := h.Service.PromoteCollaborator(r.Context(), params.AppID, params.CollaboratorID)
220+
if err != nil {
221+
writeError(w, r, err)
222+
return
223+
}
224+
225+
SiteAdminAPISuccessResponse{Body: collaborator}.WriteTo(w)
226+
}
227+
```
228+
229+
## DI Wiring
230+
231+
`pkg/siteadmin/deps.go` — add:
232+
```go
233+
wire.Bind(new(transport.CollaboratorPromoteService), new(*siteadminservice.CollaboratorService)),
234+
```
235+
236+
---
237+
238+
## Test Plan
239+
240+
Add `UpdateCollaborator` to `fakeCollaboratorStore` in `pkg/siteadmin/service/collaborator_test.go`:
241+
242+
```go
243+
updated []*portalmodel.Collaborator
244+
245+
func (f *fakeCollaboratorStore) UpdateCollaborator(_ context.Context, c *portalmodel.Collaborator) error {
246+
f.updated = append(f.updated, c)
247+
if existing, ok := f.existingByID[c.ID]; ok {
248+
existing.Role = c.Role
249+
}
250+
return nil
251+
}
252+
```
253+
254+
Four new test cases under `TestCollaboratorService`:
255+
256+
1. **PromoteCollaborator succeeds** — target is editor, current owner exists; after call: target role is owner, old owner role is editor; returned collaborator has resolved email; AdminAPI called outside TX.
257+
2. **PromoteCollaborator returns not found when collaboratorID is missing**`existingByID` is empty.
258+
3. **PromoteCollaborator returns not found on cross-app access** — collaborator exists but `AppID != appID`.
259+
4. **PromoteCollaborator returns AlreadyOwner when target is already owner** — collaborator has `Role == owner`.
260+
261+
---
262+
263+
## Error Table
264+
265+
| Condition | Error | HTTP |
266+
|---|---|---|
267+
| collaboratorID not in DB | `portalservice.ErrCollaboratorNotFound` | 404 |
268+
| collaborator belongs to different app | `portalservice.ErrCollaboratorNotFound` | 404 |
269+
| collaborator is already owner | `ErrCollaboratorAlreadyOwner` | 409 |
270+
271+
---
272+
273+
## Atomic Commit Plan
274+
275+
### Commit 1 — DB migration: add updated_at to _portal_app_collaborator
276+
277+
Files:
278+
- `cmd/portal/cmd/cmddatabase/migrations/portal/20260429120000-add_collaborator_updated_at.sql`
279+
280+
Standalone SQL-only commit.
281+
282+
### Commit 2 — Fix all INSERT and UPDATE paths for updated_at (no model change)
283+
284+
Files:
285+
- `pkg/portal/service/collaborator.go` — update `CreateCollaborator` (add `updated_at = c.CreatedAt`)
286+
- `pkg/siteadmin/service/collaborator.go` — update `CreateCollaborator` (add `updated_at = c.CreatedAt`)
287+
- `cmd/portal/internal/collaborator.go` — update `insertCollaborator` (add `updated_at = now`), `updateCollaboratorRole` (add `Set("updated_at", now)`)
288+
289+
Verification: `go build ./pkg/portal/... ./pkg/siteadmin/... ./cmd/portal/...` · `make fmt`
290+
291+
### Commit 3 — Siteadmin service: UpdateCollaborator + ErrCollaboratorAlreadyOwner + PromoteCollaborator + tests
292+
293+
Files:
294+
- `pkg/siteadmin/service/collaborator.go` — add `ErrCollaboratorAlreadyOwner`, `UpdateCollaborator` to interface and store, implement `CollaboratorService.PromoteCollaborator`
295+
- `pkg/siteadmin/service/collaborator_test.go` — add `updated` field + `UpdateCollaborator` fake, add 4 test cases
296+
297+
Verification: `go test ./pkg/siteadmin/service/...` · `make fmt`
298+
299+
### Commit 4 — Transport interface + handler body + DI wiring + wire gen + check-tidy
300+
301+
The handler stub was scaffolded in Stage 3. This commit replaces it with the real implementation and wires everything up.
302+
303+
Files:
304+
- `pkg/siteadmin/transport/handler_collaborator_promote.go` — add `CollaboratorPromoteService` interface, `Service` field, and `ServeHTTP` body
305+
- `pkg/siteadmin/deps.go` — add `wire.Bind(new(transport.CollaboratorPromoteService), new(*siteadminservice.CollaboratorService))`
306+
- `pkg/siteadmin/wire_gen.go` — regenerated via `go generate ./pkg/siteadmin/...`
307+
- `.vettedpositions` — add new `r.Context()` positions, run `make sort-vettedpositions`
308+
309+
Run `make check-tidy`; stage regenerated/formatted output files and include in this commit.
310+
311+
Verification: `go test ./pkg/siteadmin/...` · `make fmt` · `make lint` · `make check-tidy`

0 commit comments

Comments
 (0)