mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
The five user verbs collapsed onto the user collection and the key half went to the key endpoint, so this client asks the same questions at the addresses that answer them: get-user?accessKey= -> GET /v1/iam/keys/principal (envelope) get-users?field= -> GET /v1/iam/users (the page itself) get-user?owner&email -> GET /v1/iam/users (the page itself) add-user -> POST /v1/iam/users (the record itself) Three things follow from the move rather than accompany it. The credential travels in the Authorization header. A query clientId is not a credential IAM reads, so every one of these reads would have arrived unauthenticated; the plugin installs the application credentials it already holds in Config where the client is built, and each read presents them. The collection narrows on the address and on nothing else. LookupByAttribute answers "email" and refuses any other attribute instead of sending a filter that would be ignored — an ignored filter answers with the org's first page, and every row on it reads as a match. EnsureUser reads before it writes. The create refuses a duplicate username and admits a duplicate address, so creating first would file a second row under one address and leave both unresolvable by it. A username lost to a racing provision still resolves by the address the two agree on. Where an address names two accounts the answer is a refusal, not the first row: that choice is how somebody acts under a colleague's identity. The team invite asks for two for the same reason and stays pending when it sees them. A resolved key carries the account the key endpoint names — owner/name, unique across tenants where a bare username is unique only inside one — and an unresolved one yields no user at all. Co-authored-by: Hanzo Dev <[email protected]>
220 lines
6.1 KiB
Go
220 lines
6.1 KiB
Go
package bootnode
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hanzoai/base/core"
|
|
"github.com/hanzoai/base/plugins/bootnode/models"
|
|
)
|
|
|
|
// handleListTeam lists the team members of the caller's project. Ports
|
|
// GET /team from bootnode/api/team/__init__.py.
|
|
func (p *plugin) handleListTeam(e *core.RequestEvent) error {
|
|
project, err := p.requireProject(e)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
members, err := p.app.FindRecordsByFilter(
|
|
models.TeamMembers,
|
|
"project = {:project}",
|
|
"created", 0, 0,
|
|
map[string]any{"project": project.Id},
|
|
)
|
|
if err != nil {
|
|
return e.InternalServerError("failed to list team members", err)
|
|
}
|
|
out := make([]map[string]any, 0, len(members))
|
|
for _, m := range members {
|
|
out = append(out, memberJSON(m))
|
|
}
|
|
return e.JSON(http.StatusOK, map[string]any{"members": out, "total": len(out)})
|
|
}
|
|
|
|
// handleInviteMember invites a member by email. If the email already maps to an
|
|
// IAM user the membership is active immediately; otherwise it is pending with
|
|
// an invite token. Ports POST /team.
|
|
func (p *plugin) handleInviteMember(e *core.RequestEvent) error {
|
|
id, err := p.requireUser(e)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if id.ReadOnly {
|
|
return e.ForbiddenError("publishable keys are read-only", nil)
|
|
}
|
|
project, err := p.requireProject(e)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var body struct {
|
|
Email string `json:"email"`
|
|
Role string `json:"role"`
|
|
}
|
|
if err := e.BindBody(&body); err != nil {
|
|
return e.BadRequestError("invalid request body", err)
|
|
}
|
|
body.Email = strings.TrimSpace(strings.ToLower(body.Email))
|
|
if body.Email == "" || !strings.Contains(body.Email, "@") {
|
|
return e.BadRequestError("a valid email is required", nil)
|
|
}
|
|
role := body.Role
|
|
if role == "" {
|
|
role = "viewer"
|
|
}
|
|
if !validMemberRole(role) {
|
|
return e.BadRequestError("role must be one of: owner, admin, member, viewer", nil)
|
|
}
|
|
|
|
// Reject duplicates within the project.
|
|
existing, _ := p.app.FindRecordsByFilter(
|
|
models.TeamMembers,
|
|
"project = {:project} && email = {:email}",
|
|
"", 1, 0,
|
|
map[string]any{"project": project.Id, "email": body.Email},
|
|
)
|
|
if len(existing) > 0 {
|
|
return e.BadRequestError("member with this email already exists", nil)
|
|
}
|
|
|
|
// Does the email already resolve to an IAM user in this org? Ask for two, so
|
|
// an address held by two accounts is VISIBLE: seating the invitee on whichever
|
|
// row sorted first is how somebody joins a team under a colleague's identity.
|
|
// An ambiguous address stays pending and goes through the invite instead.
|
|
var iamUserID, iamName string
|
|
if users, lookupErr := p.iam.LookupByAttribute(e.Request.Context(), "email", body.Email, id.Org, 2); lookupErr == nil && len(users) == 1 {
|
|
iamUserID = users[0].ID
|
|
iamName = users[0].Name
|
|
}
|
|
|
|
col, err := p.app.FindCollectionByNameOrId(models.TeamMembers)
|
|
if err != nil {
|
|
return e.InternalServerError("team members collection not found", err)
|
|
}
|
|
rec := core.NewRecord(col)
|
|
rec.Set("project", project.Id)
|
|
rec.Set("email", body.Email)
|
|
rec.Set("role", role)
|
|
rec.Set("invitedBy", id.UserID)
|
|
if iamUserID != "" {
|
|
rec.Set("userId", iamUserID)
|
|
rec.Set("name", iamName)
|
|
rec.Set("status", "active")
|
|
rec.Set("joinedAt", time.Now().UTC())
|
|
} else {
|
|
rec.Set("status", "pending")
|
|
rec.Set("inviteToken", randomToken())
|
|
}
|
|
if err := p.app.Save(rec); err != nil {
|
|
return e.InternalServerError("failed to create member", err)
|
|
}
|
|
return e.JSON(http.StatusCreated, memberJSON(rec))
|
|
}
|
|
|
|
// handleUpdateMember updates a member's role or display name. Ports
|
|
// PATCH /team/{id}.
|
|
func (p *plugin) handleUpdateMember(e *core.RequestEvent) error {
|
|
id, err := p.requireUser(e)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if id.ReadOnly {
|
|
return e.ForbiddenError("publishable keys are read-only", nil)
|
|
}
|
|
project, err := p.requireProject(e)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
member, err := p.memberInProject(project, e.Request.PathValue("id"))
|
|
if err != nil {
|
|
return e.NotFoundError("team member not found", err)
|
|
}
|
|
|
|
var body struct {
|
|
Role string `json:"role"`
|
|
Name string `json:"name"`
|
|
}
|
|
if err := e.BindBody(&body); err != nil {
|
|
return e.BadRequestError("invalid request body", err)
|
|
}
|
|
if body.Role != "" {
|
|
if !validMemberRole(body.Role) {
|
|
return e.BadRequestError("invalid role", nil)
|
|
}
|
|
member.Set("role", body.Role)
|
|
}
|
|
if body.Name != "" {
|
|
member.Set("name", body.Name)
|
|
}
|
|
if err := p.app.Save(member); err != nil {
|
|
return e.InternalServerError("failed to update member", err)
|
|
}
|
|
return e.JSON(http.StatusOK, memberJSON(member))
|
|
}
|
|
|
|
// handleRemoveMember removes a member. Ports DELETE /team/{id}.
|
|
func (p *plugin) handleRemoveMember(e *core.RequestEvent) error {
|
|
id, err := p.requireUser(e)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if id.ReadOnly {
|
|
return e.ForbiddenError("publishable keys are read-only", nil)
|
|
}
|
|
project, err := p.requireProject(e)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
member, err := p.memberInProject(project, e.Request.PathValue("id"))
|
|
if err != nil {
|
|
return e.NotFoundError("team member not found", err)
|
|
}
|
|
if err := p.app.Delete(member); err != nil {
|
|
return e.InternalServerError("failed to remove member", err)
|
|
}
|
|
return e.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
// memberInProject finds a member record and verifies it belongs to project.
|
|
func (p *plugin) memberInProject(project *core.Record, memberID string) (*core.Record, error) {
|
|
rec, err := p.app.FindRecordById(models.TeamMembers, memberID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if rec.GetString("project") != project.Id {
|
|
return nil, errString("member does not belong to project")
|
|
}
|
|
return rec, nil
|
|
}
|
|
|
|
func memberJSON(m *core.Record) map[string]any {
|
|
return map[string]any{
|
|
"id": m.Id,
|
|
"email": m.GetString("email"),
|
|
"name": m.GetString("name"),
|
|
"role": m.GetString("role"),
|
|
"status": m.GetString("status"),
|
|
"joinedAt": m.GetString("joinedAt"),
|
|
"created": m.GetString("created"),
|
|
}
|
|
}
|
|
|
|
func validMemberRole(role string) bool {
|
|
switch role {
|
|
case "owner", "admin", "member", "viewer":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// randomToken returns a URL-safe 32-byte invite token.
|
|
func randomToken() string {
|
|
buf := make([]byte, 32)
|
|
_, _ = rand.Read(buf)
|
|
return base64.RawURLEncoding.EncodeToString(buf)
|
|
}
|