Compare commits

...
9 Commits
Author SHA1 Message Date
mrhid6andClaude Opus 5 7b077905e2 feat(web): hq-sourced members are read-only here
Server Deploy / deploy (push) Successful in 2m56s
The lock is a courtesy — the API answers 409 either way. NEXT_PUBLIC_HQ_URL
defaults empty so a self-hosted install shows a plain label rather than a
link to a portal that does not serve them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 16:42:47 +01:00
mrhid6andClaude Opus 5 2d12669f9b feat(adminsite): people, instance members and one password
The members panel is absent for self-hosted instances rather than disabled:
the backend refuses those, and a panel rendering controls the server will
reject is a panel that lies.

/auth/me now reports the caller's account role, so the UI hides what the
backend would refuse rather than discovering it in an error toast.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 16:38:21 +01:00
mrhid6andClaude Opus 5 a05a74cf4d feat(server): refuse local edits to hq-sourced users
The API is the boundary; hiding the control in web/ is a courtesy. A role
editable in two places is a role with two answers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 16:31:27 +01:00
mrhid6andClaude Opus 5 14b855fa9f feat(admin): hqsync repairs stale projected passwords
Its own package rather than a pass inside inject: inject writes three
licence fields and nothing else, and that narrowness is what makes admin's
reach into the control plane reviewable.

Repairs by copying HQ's hash, not by re-hashing — two bcrypt hashes of one
password differ by salt, so a re-hash would never converge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 16:30:33 +01:00
mrhid6andClaude Opus 5 0f5ad1d836 feat(admin): one password change reaches every instance
Best-effort by design: refusing the change because one instance was
unreachable would leave the customer holding the password they were trying
to replace. A failure is flagged and hqsync repairs it.

Also corrects two pieces of copy this makes false — CreateInstance's doc
comment and the instance-ready email both claimed the two passwords
diverge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 16:29:50 +01:00
mrhid6andClaude Opus 5 cca0ffbeae feat(admin): grant, re-role and revoke instance members
A grant writes a real control-plane user; the instance_members row is only
admin's index of it, which is why a failed insert unwinds the projection.
Self-hosted instances refuse all three mutations: their users live in a
deployment we cannot see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 16:28:41 +01:00
mrhid6andClaude Opus 5 0c663945ee feat(admin): invite people to an account and give them roles
An invitation carries no password. The HQ password is what signs someone
into every instance they are granted, so a password the inviter chose would
be a shared credential to all of them — the invited row has an empty hash,
which cannot authenticate, until /accept-invite sets one.

Removing a person revokes every projected instance user first, and refuses
outright if any of those is an instance's last owner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 16:26:56 +01:00
mrhid6andClaude Opus 5 b37ed967b6 feat(admin): cloudprov projects, revokes and repairs users
A grant is a real control-plane users row with auth_source hq, not a
federation shim: the instance authenticates it with no runtime dependency
on admin. CountOtherOwners counts control-plane owners so a locally-created
owner satisfies the last-owner rule too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 16:23:46 +01:00
mrhid6andClaude Opus 5 a7b9af4422 feat(admin): account roles and the instance_members index
Phase 2 created cloud instances without recording who owns them on this
side, because the collection did not exist. The boot backfill reconstructs
one member row per instance from the hq-sourced control-plane owner, and
marks every existing customer_user an owner — they all created their own
account.

Backfill lives in models rather than db: db is the connection layer and
models already imports it for SeedPlans, so db -> models would cycle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 16:23:02 +01:00
31 changed files with 1909 additions and 40 deletions
+1
View File
@@ -33,6 +33,7 @@ jobs:
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/web:latest"
docker build \
--build-arg NEXT_PUBLIC_API_URL="${{ vars.API_URL }}" \
--build-arg NEXT_PUBLIC_HQ_URL="${{ vars.HQ_URL }}" \
-t "$IMAGE" \
-f web/Dockerfile web/
docker push "$IMAGE"
+6
View File
@@ -15,6 +15,7 @@ import (
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/config"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/hqsync"
"github.com/mrhid6/vantage/admin/internal/inject"
"github.com/mrhid6/vantage/admin/internal/licensing"
"github.com/mrhid6/vantage/admin/internal/lifecycle"
@@ -67,11 +68,16 @@ func main() {
idxCancel()
log.Fatalf("plan seed: %v", err)
}
if err := models.Backfill(idxCtx); err != nil {
idxCancel()
log.Fatalf("backfill: %v", err)
}
idxCancel()
reconcileCtx, stopReconcile := context.WithCancel(context.Background())
defer stopReconcile()
inject.StartReconciler(reconcileCtx)
hqsync.Start(reconcileCtx)
lifecycle.SetPortalURL(cfg.PublicURL)
lifecycle.Start(reconcileCtx, cfg.ReapAfter)
+30 -5
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/cloudprov"
@@ -19,6 +20,7 @@ import (
"github.com/mrhid6/vantage/admin/internal/mail"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"github.com/mrhid6/vantage/shared/provision"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -55,11 +57,15 @@ func getMe(c *gin.Context) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "not signed in"})
return
}
c.JSON(http.StatusOK, gin.H{
"kind": s.Kind,
"email": s.Email,
"account_id": s.AccountID,
})
out := gin.H{"kind": s.Kind, "email": s.Email, "account_id": s.AccountID}
if s.Kind == auth.KindCustomer {
var u models.CustomerUser
if err := db.Admin("customer_users").FindOne(c.Request.Context(),
bson.M{"user_id": s.UserID}).Decode(&u); err == nil {
out["account_role"] = u.AccountRole
}
}
c.JSON(http.StatusOK, out)
}
func getAccount(c *gin.Context) {
@@ -277,6 +283,25 @@ func createInstance(c *gin.Context) {
return
}
// Record the owner's membership. Best-effort: the projected user already
// exists and is what actually grants access, so a missing row here costs a
// line in the members panel, not access — and the boot backfill rebuilds it.
ownerID, err := cloudprov.OwnerUserID(ctx, inst.InstanceID)
if err != nil {
log.Printf("createInstance: owner lookup for %s: %v", inst.InstanceID, err)
} else if _, err := db.Admin("instance_members").InsertOne(ctx, models.InstanceMember{
MemberID: uuid.NewString(),
AccountID: s.AccountID,
InstanceID: inst.InstanceID,
CustomerUserID: cu.UserID,
ControlUserID: ownerID,
Role: sharedmodels.RoleOwner,
Email: cu.Email,
CreatedAt: time.Now().UTC(),
}); err != nil {
log.Printf("createInstance: record owner membership for %s: %v", inst.InstanceID, err)
}
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance.created", AccountID: s.AccountID,
Target: inst.InstanceID, Detail: "slug=" + inst.Slug, IP: c.ClientIP()})
+237
View File
@@ -0,0 +1,237 @@
package api
import (
"errors"
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/cloudprov"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"github.com/mrhid6/vantage/shared/provision"
"go.mongodb.org/mongo-driver/v2/bson"
)
// selfHostedRefusal is the one message every membership endpoint gives for a
// self-hosted instance. Their users live in their own deployment, which we
// cannot see and must not write to.
const selfHostedRefusal = "this install manages its own users; add them in Settings → Instance inside your Vantage install"
func listInstanceMembers(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
ctx := c.Request.Context()
cur, err := db.Admin("instance_members").Find(ctx,
bson.M{"instance_id": inst.InstanceID})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
members := []models.InstanceMember{}
if err := cur.All(ctx, &members); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, members)
}
// grantInstanceMember projects an account person into a cloud instance.
func grantInstanceMember(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
if inst.Deployment != license.DeploymentCloud {
c.JSON(http.StatusBadRequest, gin.H{"error": selfHostedRefusal})
return
}
var body struct {
UserID string `json:"user_id"`
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.UserID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
return
}
if body.Role == "" {
body.Role = sharedmodels.RoleMember
}
if !sharedmodels.ValidRole(body.Role) {
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
return
}
target, ok := accountUser(c, body.UserID)
if !ok {
return
}
if target.VerifiedAt == nil || target.PasswordHash == "" {
// The projection copies a hash. An unverified invitee has no hash, so
// the row would exist and be unusable — and an address nobody has
// proven they control would hold a login inside a real instance.
c.JSON(http.StatusConflict, gin.H{
"error": "they have not accepted their invitation yet"})
return
}
ctx := c.Request.Context()
s := auth.Current(c)
u, err := cloudprov.GrantUser(ctx, inst.InstanceID, target.Email,
target.PasswordHash, body.Role, target.UserID)
if err != nil {
if errors.Is(err, provision.ErrEmailTaken) {
c.JSON(http.StatusConflict, gin.H{
"error": "that address already has a user inside this instance"})
return
}
log.Printf("grant %s to %s: %v", target.Email, inst.InstanceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not grant access"})
return
}
m := models.InstanceMember{
MemberID: uuid.NewString(),
AccountID: inst.AccountID,
InstanceID: inst.InstanceID,
CustomerUserID: target.UserID,
ControlUserID: u.UserID,
Role: body.Role,
Email: target.Email,
CreatedAt: time.Now().UTC(),
}
if _, err := db.Admin("instance_members").InsertOne(ctx, m); err != nil {
// Unwind the projection: a control-plane login nobody on this side
// records is a login nobody can revoke through the portal.
if rErr := cloudprov.RevokeUser(ctx, inst.InstanceID, target.UserID); rErr != nil {
log.Printf("grant: FAILED to unwind projection of %s in %s: %v",
target.Email, inst.InstanceID, rErr)
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not grant access"})
return
}
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance_member.granted", AccountID: s.AccountID,
Target: inst.InstanceID, Detail: target.Email + " role=" + body.Role, IP: c.ClientIP()})
c.JSON(http.StatusCreated, m)
}
// memberRow loads one membership on an instance the caller owns.
func memberRow(c *gin.Context, instanceID, customerUserID string) (*models.InstanceMember, bool) {
var m models.InstanceMember
if err := db.Admin("instance_members").FindOne(c.Request.Context(), bson.M{
"instance_id": instanceID,
"customer_user_id": customerUserID,
}).Decode(&m); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return nil, false
}
return &m, true
}
func updateInstanceMemberRole(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
if inst.Deployment != license.DeploymentCloud {
c.JSON(http.StatusBadRequest, gin.H{"error": selfHostedRefusal})
return
}
var body struct {
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&body); err != nil || !sharedmodels.ValidRole(body.Role) {
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
return
}
m, ok := memberRow(c, inst.InstanceID, c.Param("uid"))
if !ok {
return
}
ctx := c.Request.Context()
if m.Role == sharedmodels.RoleOwner && body.Role != sharedmodels.RoleOwner {
others, err := cloudprov.CountOtherOwners(ctx, inst.InstanceID, m.CustomerUserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if others == 0 {
c.JSON(http.StatusConflict, gin.H{
"error": "this is the instance's last owner; make someone else an owner first"})
return
}
}
if err := cloudprov.SetMemberRole(ctx, inst.InstanceID, m.CustomerUserID, body.Role); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not change their role"})
return
}
if _, err := db.Admin("instance_members").UpdateOne(ctx,
bson.M{"member_id": m.MemberID},
bson.M{"$set": bson.M{"role": body.Role}}); err != nil {
log.Printf("member role: control plane updated but member row %s did not: %v", m.MemberID, err)
}
s := auth.Current(c)
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance_member.role_changed", AccountID: s.AccountID,
Target: inst.InstanceID, Detail: m.Email + " role=" + body.Role, IP: c.ClientIP()})
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func revokeInstanceMember(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
if inst.Deployment != license.DeploymentCloud {
c.JSON(http.StatusBadRequest, gin.H{"error": selfHostedRefusal})
return
}
m, ok := memberRow(c, inst.InstanceID, c.Param("uid"))
if !ok {
return
}
ctx := c.Request.Context()
if m.Role == sharedmodels.RoleOwner {
others, err := cloudprov.CountOtherOwners(ctx, inst.InstanceID, m.CustomerUserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if others == 0 {
c.JSON(http.StatusConflict, gin.H{
"error": "this is the instance's last owner; make someone else an owner first"})
return
}
}
if err := cloudprov.RevokeUser(ctx, inst.InstanceID, m.CustomerUserID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not revoke access"})
return
}
if _, err := db.Admin("instance_members").DeleteOne(ctx,
bson.M{"member_id": m.MemberID}); err != nil {
log.Printf("revoke: control-plane user deleted but member row %s remains: %v", m.MemberID, err)
}
s := auth.Current(c)
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance_member.revoked", AccountID: s.AccountID,
Target: inst.InstanceID, Detail: m.Email, IP: c.ClientIP()})
c.JSON(http.StatusOK, gin.H{"revoked": true})
}
+338
View File
@@ -0,0 +1,338 @@
package api
import (
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/cloudprov"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
// listAccountUsers returns the account's people, newest last.
//
// Any signed-in member may read this. Knowing who your colleagues are is not
// privileged, and hiding it would make the members panel unusable for the
// people it is meant to inform.
func listAccountUsers(c *gin.Context) {
s := auth.Current(c)
ctx := c.Request.Context()
cur, err := db.Admin("customer_users").Find(ctx, bson.M{"account_id": s.AccountID})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
users := []models.CustomerUser{}
if err := cur.All(ctx, &users); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, users)
}
// inviteAccountUser adds a person to the account.
//
// It never sets a password: see CreateInvitedUser. Only an owner may invite
// another owner, mirroring the control plane's own rule that an admin cannot
// mint someone with more power than themselves.
func inviteAccountUser(c *gin.Context) {
var body struct {
Email string `json:"email"`
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "email is required"})
return
}
email := strings.ToLower(strings.TrimSpace(body.Email))
if email == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "email is required"})
return
}
if body.Role == "" {
body.Role = models.AccountRoleMember
}
if !models.ValidAccountRole(body.Role) {
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
return
}
me := auth.CurrentUser(c)
if body.Role == models.AccountRoleOwner && me.AccountRole != models.AccountRoleOwner {
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can invite another owner"})
return
}
ctx := c.Request.Context()
s := auth.Current(c)
// customer_users.email is globally unique, so an address already in use
// anywhere cannot be invited here. Say so plainly: unlike signup there is
// nothing to conceal, because the inviter already knows this address.
if n, _ := db.Admin("customer_users").CountDocuments(ctx, bson.M{"email": email}); n > 0 {
c.JSON(http.StatusConflict, gin.H{
"error": "that address already has a Vantage HQ account"})
return
}
var acct models.Account
if err := db.Admin("accounts").FindOne(ctx,
bson.M{"account_id": s.AccountID}).Decode(&acct); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not read your account"})
return
}
if err := auth.CreateInvitedUser(ctx, s.AccountID, acct.Name, email, body.Role); err != nil {
log.Printf("invite %s to %s: %v", email, s.AccountID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not send the invitation"})
return
}
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "account_user.invited", AccountID: s.AccountID,
Target: email, Detail: "role=" + body.Role, IP: c.ClientIP()})
c.JSON(http.StatusCreated, gin.H{"invited": true})
}
// accountUser loads one person and confirms they are on the caller's account.
func accountUser(c *gin.Context, userID string) (*models.CustomerUser, bool) {
s := auth.Current(c)
var u models.CustomerUser
if err := db.Admin("customer_users").FindOne(c.Request.Context(),
bson.M{"user_id": userID, "account_id": s.AccountID}).Decode(&u); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return nil, false
}
return &u, true
}
// countOtherAccountOwners counts owners of an account other than one person.
func countOtherAccountOwners(c *gin.Context, exceptUserID string) (int64, error) {
s := auth.Current(c)
return db.Admin("customer_users").CountDocuments(c.Request.Context(), bson.M{
"account_id": s.AccountID,
"account_role": models.AccountRoleOwner,
"user_id": bson.M{"$ne": exceptUserID},
})
}
func updateAccountUserRole(c *gin.Context) {
var body struct {
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&body); err != nil || !models.ValidAccountRole(body.Role) {
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
return
}
target, ok := accountUser(c, c.Param("id"))
if !ok {
return
}
me := auth.CurrentUser(c)
if target.UserID == me.UserID {
c.JSON(http.StatusForbidden, gin.H{"error": "you cannot change your own role"})
return
}
if (body.Role == models.AccountRoleOwner || target.AccountRole == models.AccountRoleOwner) &&
me.AccountRole != models.AccountRoleOwner {
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can change owner roles"})
return
}
if target.AccountRole == models.AccountRoleOwner && body.Role != models.AccountRoleOwner {
others, err := countOtherAccountOwners(c, target.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if others == 0 {
c.JSON(http.StatusConflict, gin.H{
"error": "this is the account's last owner; promote someone else first"})
return
}
}
ctx := c.Request.Context()
if _, err := db.Admin("customer_users").UpdateOne(ctx,
bson.M{"user_id": target.UserID},
bson.M{"$set": bson.M{"account_role": body.Role}}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
s := auth.Current(c)
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "account_user.role_changed", AccountID: s.AccountID,
Target: target.Email, Detail: "role=" + body.Role, IP: c.ClientIP()})
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// deleteAccountUser removes a person and every instance they hold.
//
// Grants go first, and the whole request is refused if any of them would strand
// an instance with no owner. Removing the person but leaving their projected
// rows behind would leave working logins for someone the account has removed —
// the exact failure this endpoint exists to prevent.
func deleteAccountUser(c *gin.Context) {
target, ok := accountUser(c, c.Param("id"))
if !ok {
return
}
me := auth.CurrentUser(c)
if target.UserID == me.UserID {
c.JSON(http.StatusForbidden, gin.H{"error": "you cannot remove your own account"})
return
}
if target.AccountRole == models.AccountRoleOwner && me.AccountRole != models.AccountRoleOwner {
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can remove another owner"})
return
}
if target.AccountRole == models.AccountRoleOwner {
others, err := countOtherAccountOwners(c, target.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if others == 0 {
c.JSON(http.StatusConflict, gin.H{
"error": "this is the account's last owner; promote someone else first"})
return
}
}
ctx := c.Request.Context()
s := auth.Current(c)
cur, err := db.Admin("instance_members").Find(ctx,
bson.M{"customer_user_id": target.UserID})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
members := []models.InstanceMember{}
if err := cur.All(ctx, &members); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Check every instance BEFORE deleting anything, so a refusal leaves the
// person exactly as they were rather than half-revoked.
for _, m := range members {
if m.Role != sharedmodels.RoleOwner {
continue
}
others, err := cloudprov.CountOtherOwners(ctx, m.InstanceID, target.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if others == 0 {
c.JSON(http.StatusConflict, gin.H{
"error": "they are the last owner of an instance; give someone else that instance's owner role first"})
return
}
}
for _, m := range members {
if err := cloudprov.RevokeUser(ctx, m.InstanceID, target.UserID); err != nil {
log.Printf("deleteAccountUser: revoke %s from %s: %v", target.Email, m.InstanceID, err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "could not remove their instance access; nothing was deleted"})
return
}
if _, err := db.Admin("instance_members").DeleteOne(ctx,
bson.M{"member_id": m.MemberID}); err != nil {
log.Printf("deleteAccountUser: drop member row %s: %v", m.MemberID, err)
}
}
if _, err := db.Admin("customer_users").DeleteOne(ctx,
bson.M{"user_id": target.UserID}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "account_user.removed", AccountID: s.AccountID,
Target: target.Email, Detail: fmt.Sprintf("revoked %d instance(s)", len(members)),
IP: c.ClientIP()})
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
// changeAccountPassword sets one password and pushes it everywhere.
//
// HQ's hash is the single source of truth for every hq-sourced row, and the
// control plane has no local password-change path for them, so there is no
// competing writer.
//
// Propagation is best-effort ON PURPOSE. Failing the password change because
// one of three instances was briefly unreachable would leave the customer with
// the password they were trying to get rid of; hqsync repairs a stale instance
// within fifteen minutes, which is recoverable.
func changeAccountPassword(c *gin.Context) {
var body struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "current and new password are required"})
return
}
if len(body.NewPassword) < 12 {
c.JSON(http.StatusBadRequest, gin.H{"error": "choose a password of at least 12 characters"})
return
}
s := auth.Current(c)
ctx := c.Request.Context()
var me models.CustomerUser
if err := db.Admin("customer_users").FindOne(ctx,
bson.M{"user_id": s.UserID}).Decode(&me); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
return
}
if bcrypt.CompareHashAndPassword([]byte(me.PasswordHash), []byte(body.CurrentPassword)) != nil {
c.JSON(http.StatusForbidden, gin.H{"error": "that is not your current password"})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), auth.BcryptCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not set the password"})
return
}
if _, err := db.Admin("customer_users").UpdateOne(ctx,
bson.M{"user_id": me.UserID},
bson.M{"$set": bson.M{"password_hash": string(hash)}}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not set the password"})
return
}
pending := false
if n, err := cloudprov.SetPasswordHash(ctx, me.UserID, string(hash)); err != nil {
pending = true
now := time.Now().UTC()
log.Printf("password: propagation for %s failed, hqsync will repair: %v", me.Email, err)
_, _ = db.Admin("customer_users").UpdateOne(ctx,
bson.M{"user_id": me.UserID},
bson.M{"$set": bson.M{"hq_sync_failed_at": now}})
} else {
log.Printf("password: %s propagated to %d instance user(s)", me.Email, n)
_, _ = db.Admin("customer_users").UpdateOne(ctx,
bson.M{"user_id": me.UserID},
bson.M{"$unset": bson.M{"hq_sync_failed_at": ""}})
}
audit.Write(ctx, models.AuditEntry{
Actor: me.Email, Action: "account_user.password_changed", AccountID: s.AccountID,
Target: me.Email, IP: c.ClientIP()})
c.JSON(http.StatusOK, gin.H{"updated": true, "propagation_pending": pending})
}
+34 -1
View File
@@ -13,6 +13,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/config"
"github.com/mrhid6/vantage/admin/internal/models"
)
func Routes(cfg config.Config) http.Handler {
@@ -36,17 +37,49 @@ func Routes(cfg config.Config) http.Handler {
r.GET("/auth/verify", auth.HandleVerify)
r.GET("/auth/me", getMe)
r.POST("/auth/signup", auth.HandleSignup)
r.POST("/auth/accept-invite", auth.HandleAcceptInvite)
cust := r.Group("/api")
cust.Use(auth.RequireCustomer())
{
cust.GET("/account", getAccount)
cust.POST("/instances", createInstance)
// People. Reading is open to any member; changing anything is
// owner-or-admin, enforced per route rather than by splitting the group,
// so the guard is visible next to the route it guards.
cust.GET("/account/users", listAccountUsers)
cust.POST("/account/users",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
inviteAccountUser)
cust.PUT("/account/users/:id/role",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
updateAccountUserRole)
cust.DELETE("/account/users/:id",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
deleteAccountUser)
// Any member may change their own password — it is theirs. There is no
// endpoint for changing anyone else's.
cust.PUT("/account/password", changeAccountPassword)
cust.POST("/instances",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
createInstance)
cust.POST("/instances/link", linkInstance)
cust.POST("/instances/:id/relink", relinkInstance)
cust.POST("/instances/:id/renew", renewInstance)
cust.GET("/instances/:id/license", getInstanceLicense)
cust.GET("/instances/:id/license/download", downloadInstanceLicense)
cust.GET("/instances/:id/members", listInstanceMembers)
cust.POST("/instances/:id/members",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
grantInstanceMember)
cust.PUT("/instances/:id/members/:uid/role",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
updateInstanceMemberRole)
cust.DELETE("/instances/:id/members/:uid",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
revokeInstanceMember)
cust.GET("/subscriptions", listSubscriptions)
}
+3 -1
View File
@@ -476,7 +476,9 @@ func staffCreateAccountUser(c *gin.Context) {
}
email := strings.ToLower(strings.TrimSpace(body.Email))
if err := auth.CreateCustomerUser(ctx, accountID, email, body.Password); err != nil {
// Staff attaching a legacy customer are attaching the person who runs that
// account, so they get owner.
if err := auth.CreateCustomerUser(ctx, accountID, email, body.Password, models.AccountRoleOwner); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
+110 -6
View File
@@ -28,9 +28,9 @@ const BcryptCost = 12
// SHA-256 hash stored, 24-hour expiry.
const VerifyWindow = 24 * time.Hour
// CreateCustomerUser creates an unverified self-hosted customer login and emails
// the verification link. Called during purchase (spec 5) and by staff.
func CreateCustomerUser(ctx context.Context, accountID, email, password string) error {
// CreateCustomerUser creates an unverified HQ login with a chosen password and
// emails the verification link. Used by signup and by staff.
func CreateCustomerUser(ctx context.Context, accountID, email, password, accountRole string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
if err != nil {
return err
@@ -49,6 +49,7 @@ func CreateCustomerUser(ctx context.Context, accountID, email, password string)
AccountID: accountID,
Email: strings.ToLower(strings.TrimSpace(email)),
PasswordHash: string(hash),
AccountRole: accountRole,
VerifyTokenHash: hex.EncodeToString(sum[:]),
VerifyTokenExpiry: &expiry,
CreatedAt: time.Now().UTC(),
@@ -79,6 +80,91 @@ func CreateCustomerUser(ctx context.Context, accountID, email, password string)
return nil
}
// CreateInvitedUser creates a passwordless, unverified member of an existing
// account and emails them a link to set a password.
//
// The empty hash is load-bearing: bcrypt.CompareHashAndPassword against "" can
// never succeed, so the row cannot sign in and cannot usefully be projected
// into an instance until the invitee has been through /accept-invite. That is
// also why a grant refuses an unverified user.
func CreateInvitedUser(ctx context.Context, accountID, accountName, email, accountRole string) error {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return err
}
token := hex.EncodeToString(raw)
sum := sha256.Sum256([]byte(token))
expiry := time.Now().UTC().Add(VerifyWindow)
u := models.CustomerUser{
UserID: uuid.NewString(),
AccountID: accountID,
Email: strings.ToLower(strings.TrimSpace(email)),
AccountRole: accountRole,
VerifyTokenHash: hex.EncodeToString(sum[:]),
VerifyTokenExpiry: &expiry,
CreatedAt: time.Now().UTC(),
}
if _, err := db.Admin("customer_users").InsertOne(ctx, u); err != nil {
return err
}
if err := mail.SendInvite(u.Email, accountName, token); err != nil {
// Same rollback rule, and the same detached context, as signup: a row
// whose link was never delivered can never be signed in to and holds
// the unique index on email against the person it was meant for.
rbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer cancel()
if _, dErr := db.Admin("customer_users").DeleteOne(rbCtx, bson.M{"user_id": u.UserID}); dErr != nil {
log.Printf("invite: FAILED to roll back customer_user %s (%s) after mail error: %v",
u.UserID, u.Email, dErr)
}
return err
}
return nil
}
// HandleAcceptInvite consumes an invitation token and sets the password.
//
// Verification and password-setting are one step for an invitee, because the
// link IS the proof of address and there is nothing to verify separately.
func HandleAcceptInvite(c *gin.Context) {
var body struct {
Token string `json:"token"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Token == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing token"})
return
}
if len(body.Password) < 12 {
c.JSON(http.StatusBadRequest, gin.H{"error": "choose a password of at least 12 characters"})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), BcryptCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not set the password"})
return
}
sum := sha256.Sum256([]byte(body.Token))
now := time.Now().UTC()
res, err := db.Admin("customer_users").UpdateOne(c.Request.Context(),
bson.M{
"verify_token_hash": hex.EncodeToString(sum[:]),
"verify_token_expiry": bson.M{"$gt": now},
},
bson.M{
"$set": bson.M{"verified_at": now, "password_hash": string(hash)},
"$unset": bson.M{"verify_token_hash": "", "verify_token_expiry": ""},
})
if err != nil || res.MatchedCount == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "that link is invalid or has expired"})
return
}
c.JSON(http.StatusOK, gin.H{"accepted": true})
}
// HandleSignup creates a self-hosted customer: an account, an unverified user,
// and a verification email.
//
@@ -134,7 +220,7 @@ func HandleSignup(c *gin.Context) {
return
}
if err := CreateCustomerUser(ctx, acct.AccountID, email, body.Password); err != nil {
if err := CreateCustomerUser(ctx, acct.AccountID, email, body.Password, models.AccountRoleOwner); err != nil {
// Roll the account back rather than strand one with no owner. Detached
// from ctx for the same reason as the user rollback above: a stalled mail
// server cancels the request, and a rollback that needs the request to
@@ -163,10 +249,28 @@ func HandleVerify(c *gin.Context) {
}
sum := sha256.Sum256([]byte(token))
now := time.Now().UTC()
ctx := c.Request.Context()
hashed := hex.EncodeToString(sum[:])
res, err := db.Admin("customer_users").UpdateOne(c.Request.Context(),
// Peek first. An invited row has no password yet, so consuming its token
// here would verify an account nobody can sign in to and burn the only
// link that could fix it.
var u models.CustomerUser
if err := db.Admin("customer_users").FindOne(ctx, bson.M{
"verify_token_hash": hashed,
"verify_token_expiry": bson.M{"$gt": now},
}).Decode(&u); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "that link is invalid or has expired"})
return
}
if u.PasswordHash == "" {
c.JSON(http.StatusOK, gin.H{"verified": false, "needs_password": true})
return
}
res, err := db.Admin("customer_users").UpdateOne(ctx,
bson.M{
"verify_token_hash": hex.EncodeToString(sum[:]),
"verify_token_hash": hashed,
"verify_token_expiry": bson.M{"$gt": now},
},
bson.M{
+51
View File
@@ -2,8 +2,12 @@ package auth
import (
"net/http"
"slices"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
)
const ctxSession = "admin_session_obj"
@@ -58,3 +62,50 @@ func RequireCustomer() gin.HandlerFunc {
c.Next()
}
}
const ctxCustomerUser = "admin_customer_user"
// CurrentUser returns the calling customer's own row, loaded once per request
// by RequireAccountRole.
//
// It is nil behind RequireCustomer alone. A handler that needs the role must
// sit behind RequireAccountRole, which is the only thing that loads it.
func CurrentUser(c *gin.Context) *models.CustomerUser {
if v, ok := c.Get(ctxCustomerUser); ok {
if u, ok := v.(*models.CustomerUser); ok {
return u
}
}
return nil
}
// RequireAccountRole admits a customer holding one of the given account roles.
//
// The role is read from the database on every request rather than carried in
// the session. A session lives 24 hours; a demotion that only takes effect
// when someone signs out again is not a demotion.
func RequireAccountRole(roles ...string) gin.HandlerFunc {
return func(c *gin.Context) {
s := Current(c)
if s == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
return
}
var u models.CustomerUser
if err := db.Admin("customer_users").FindOne(c.Request.Context(),
bson.M{"user_id": s.UserID}).Decode(&u); err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
return
}
if !slices.Contains(roles, u.AccountRole) {
// 403 rather than 404 here: this is the caller's OWN account, so
// there is no existence to disclose — the 404 rule protects other
// accounts' resources, not the caller's view of their own.
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "your account role does not allow this"})
return
}
c.Set(ctxCustomerUser, &u)
c.Next()
}
}
+103 -3
View File
@@ -24,9 +24,10 @@ import (
// CreateInstance creates a control-plane instance and its owner.
//
// The owner's password hash is COPIED from the HQ account rather than shared.
// Changing the password on either side does not propagate, and they diverge from
// that moment — accepted deliberately, because propagating a hash across two
// services' databases is a worse problem than two passwords that started equal.
// HQ remains the single source of truth: a password change there copies the new
// hash to every projected row (see SetPasswordHash), and hqsync repairs any that
// a failed write left stale. The control plane has no local password-change path
// for an hq-sourced row, so there is no competing writer.
//
// On owner-insert failure the instance is rolled back, so a failed provision
// never leaves a slug permanently occupied by an instance nobody owns.
@@ -83,3 +84,102 @@ func OwnerUserID(ctx context.Context, instanceID string) (string, error) {
}
return u.UserID, nil
}
// GrantUser projects an HQ person into a control-plane instance.
//
// The password hash is copied from customer_users rather than re-derived: HQ
// owns the password, and a grant that asked for a password again would create
// a second credential for one person.
//
// The row is written with auth_source "hq" and hq_user_id set, which is what
// makes the control plane refuse to edit it locally and what lets a password
// change find it later.
func GrantUser(ctx context.Context, instanceID, email, passwordHash, role, hqUserID string) (*sharedmodels.User, error) {
u, err := provision.CreateUserWithHash(ctx, db.ControlDB(), instanceID,
email, passwordHash, role, sharedmodels.AuthHQ)
if err != nil {
return nil, err
}
if _, err := db.Control("users").UpdateOne(ctx,
bson.M{"user_id": u.UserID},
bson.M{"$set": bson.M{"hq_user_id": hqUserID}}); err != nil {
// Unwind: a projected row with no hq_user_id is invisible to revoke and
// to password propagation, which is worse than no row at all.
_, _ = db.Control("users").DeleteOne(ctx, bson.M{"user_id": u.UserID})
return nil, fmt.Errorf("set hq_user_id: %w", err)
}
u.HQUserID = hqUserID
return u, nil
}
// RevokeUser deletes the projected row for one person in one instance.
//
// Deleting rather than disabling is deliberate: the control plane has no
// concept of a disabled user, and a row that still exists is a row that can
// still sign in.
func RevokeUser(ctx context.Context, instanceID, hqUserID string) error {
_, err := db.Control("users").DeleteOne(ctx, bson.M{
"instance_id": instanceID,
"hq_user_id": hqUserID,
})
return err
}
// SetMemberRole changes a projected user's role inside one instance.
func SetMemberRole(ctx context.Context, instanceID, hqUserID, role string) error {
if !sharedmodels.ValidRole(role) {
return fmt.Errorf("invalid role %q", role)
}
res, err := db.Control("users").UpdateOne(ctx,
bson.M{"instance_id": instanceID, "hq_user_id": hqUserID},
bson.M{"$set": bson.M{"role": role}})
if err != nil {
return err
}
if res.MatchedCount == 0 {
return fmt.Errorf("no projected user in instance %s", instanceID)
}
return nil
}
// CountOtherOwners counts owners of an instance other than one HQ person.
//
// It counts CONTROL-PLANE owners, so an owner created locally inside the
// instance counts too. That matters: refusing to revoke the last HQ owner of
// an instance that has three local owners would be a refusal with no cause.
//
// $ne matches documents where the field is absent, which is exactly how a
// locally-created owner is stored.
func CountOtherOwners(ctx context.Context, instanceID, exceptHQUserID string) (int64, error) {
return db.Control("users").CountDocuments(ctx, bson.M{
"instance_id": instanceID,
"role": sharedmodels.RoleOwner,
"hq_user_id": bson.M{"$ne": exceptHQUserID},
})
}
// SetPasswordHash writes one hash to every row projected from one HQ person,
// across every instance, and reports how many it changed.
func SetPasswordHash(ctx context.Context, hqUserID, hash string) (int64, error) {
res, err := db.Control("users").UpdateMany(ctx,
bson.M{"hq_user_id": hqUserID},
bson.M{"$set": bson.M{"password_hash": hash}})
if err != nil {
return 0, err
}
return res.ModifiedCount, nil
}
// ProjectedUsers returns every control-plane row projected from one HQ person.
// hqsync uses it to compare hashes.
func ProjectedUsers(ctx context.Context, hqUserID string) ([]sharedmodels.User, error) {
cur, err := db.Control("users").Find(ctx, bson.M{"hq_user_id": hqUserID})
if err != nil {
return nil, err
}
var users []sharedmodels.User
if err := cur.All(ctx, &users); err != nil {
return nil, err
}
return users, nil
}
+20
View File
@@ -118,5 +118,25 @@ func EnsureIndexes(ctx context.Context) error {
return fmt.Errorf("index %s: %w", idx.coll, err)
}
}
// One person holds at most one user in one instance. This is the property
// that makes a grant idempotent-by-refusal rather than silently doubling a
// projection, and it mirrors users' own (instance_id, email) uniqueness.
if _, err := Admin("instance_members").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "customer_user_id", Value: 1}},
Options: options.Index().SetUnique(true).
SetName("instance_customer_user_unique"),
}); err != nil {
return fmt.Errorf("index instance_members.(instance_id,customer_user_id): %w", err)
}
for _, keys := range []bson.D{
{{Key: "account_id", Value: 1}},
{{Key: "customer_user_id", Value: 1}},
} {
if _, err := Admin("instance_members").Indexes().CreateOne(ctx,
mongo.IndexModel{Keys: keys}); err != nil {
return fmt.Errorf("index instance_members: %w", err)
}
}
return nil
}
+116
View File
@@ -0,0 +1,116 @@
// Package hqsync keeps projected control-plane users consistent with the HQ
// people they were projected from.
//
// It is separate from inject on purpose. inject writes exactly three licence
// fields on `instances` and that narrowness is the reason admin's reach into
// the control plane is reviewable at all; a password repair pass bolted onto it
// would quietly turn it into "the package that writes whatever admin wants".
// This one goes through cloudprov, which is the sanctioned user write path.
package hqsync
import (
"context"
"log"
"time"
"github.com/mrhid6/vantage/admin/internal/cloudprov"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Interval matches inject's reconciler. Fifteen minutes is the worst-case
// staleness a password change can suffer, which the spec accepts as
// recoverable.
const Interval = 15 * time.Minute
// Reconcile compares every projected user's stored hash against the HQ hash it
// came from, and repairs mismatches.
//
// The comparison is on the hash string, not the password: two bcrypt hashes of
// one password differ by salt, so this repairs by COPYING HQ's hash rather than
// re-hashing. That is also why propagation copies rather than re-derives.
func Reconcile(ctx context.Context) (checked, repaired int, err error) {
cur, err := db.Admin("customer_users").Find(ctx,
bson.M{"password_hash": bson.M{"$nin": bson.A{nil, ""}}})
if err != nil {
return 0, 0, err
}
var people []models.CustomerUser
if err := cur.All(ctx, &people); err != nil {
return 0, 0, err
}
for _, p := range people {
projected, err := cloudprov.ProjectedUsers(ctx, p.UserID)
if err != nil {
log.Printf("hqsync: read projections of %s: %v", p.Email, err)
continue
}
stale := false
for _, u := range projected {
checked++
if u.PasswordHash != p.PasswordHash {
stale = true
}
}
if !stale {
// Clear a stale failure flag: the instances agree, whatever the
// flag says. Nothing reads the flag to decide what to repair.
if p.HQSyncFailedAt != nil {
_, _ = db.Admin("customer_users").UpdateOne(ctx,
bson.M{"user_id": p.UserID},
bson.M{"$unset": bson.M{"hq_sync_failed_at": ""}})
}
continue
}
n, err := cloudprov.SetPasswordHash(ctx, p.UserID, p.PasswordHash)
if err != nil {
log.Printf("hqsync: repair %s: %v", p.Email, err)
continue
}
repaired += int(n)
log.Printf("hqsync: repaired %d projected user(s) for %s", n, p.Email)
_, _ = db.Admin("customer_users").UpdateOne(ctx,
bson.M{"user_id": p.UserID},
bson.M{"$unset": bson.M{"hq_sync_failed_at": ""}})
}
return checked, repaired, nil
}
// Start runs once at boot, then on a ticker until ctx is cancelled.
//
// The boot pass is for the same reason inject's is: the likeliest moment for a
// half-applied write is a deploy or a crash, and waiting a full interval to
// notice means a customer's new password does not work somewhere for fifteen
// minutes after we already know how to fix it.
func Start(ctx context.Context) {
go func() {
runOnce(ctx)
t := time.NewTicker(Interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
runOnce(ctx)
}
}
}()
}
func runOnce(ctx context.Context) {
runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
checked, repaired, err := Reconcile(runCtx)
if err != nil {
log.Printf("hqsync: %v", err)
return
}
if repaired > 0 {
log.Printf("hqsync: checked %d projected user(s), repaired %d", checked, repaired)
}
}
+13 -1
View File
@@ -143,6 +143,18 @@ func SendVerification(to, token string) error {
link+"\n\nThis link expires in 24 hours.\n")
}
// SendInvite asks someone to join an existing account and set their own
// password. It names the account, because an unexpected invitation from a
// service you have never used is otherwise indistinguishable from spam.
func SendInvite(to, accountName, token string) error {
link := fmt.Sprintf("%s/accept-invite?token=%s", cfg.PublicURL, token)
return send(to, "You have been invited to "+sanitizeHeader(accountName)+" on Vantage",
fmt.Sprintf("You have been invited to join %s on Vantage.\n\n"+
"Set your password and finish joining:\n\n%s\n\n"+
"This link expires in 24 hours. If you were not expecting this, ignore it — "+
"nothing happens until you open the link.\n", accountName, link))
}
// SendLicense delivers the blob inline. It is signed public data, not a secret —
// it is useless on any instance other than the one it names.
func SendLicense(to, instanceName, blob string) error {
@@ -166,7 +178,7 @@ func SendInstanceReady(to, instanceName, loginURL string, expires time.Time) err
body += fmt.Sprintf(
"Your Free licence runs until %s. We will email you before then so you can renew it in one click.\n\n"+
"Sign in with the same email address and password you use for your Vantage account. "+
"Changing one does not change the other.\n",
"Changing your Vantage HQ password changes it here too.\n",
expires.Format("2 January 2006"))
return send(to, instanceName+" is ready", body)
}
+101
View File
@@ -0,0 +1,101 @@
package models
import (
"context"
"log"
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/shared/license"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// Backfill brings pre-phase-3 data up to the membership model.
//
// It runs on every boot and is idempotent by construction: both passes filter
// on the absence of what they write. There is no migrations collection in
// admin, and adding one for two `$exists: false` queries would be more
// machinery than the job deserves.
//
// It lives in models rather than db for the same reason SeedPlans does: db is
// the connection layer and importing models there is an import cycle.
func Backfill(ctx context.Context) error {
// Pass 1: every existing customer_user created their own account, so they
// are all owners. A row with no account_role would otherwise be able to do
// nothing at all once the guards land — including managing the account it
// created.
res, err := db.Admin("customer_users").UpdateMany(ctx,
bson.M{"account_role": bson.M{"$exists": false}},
bson.M{"$set": bson.M{"account_role": AccountRoleOwner}})
if err != nil {
return err
}
if res.ModifiedCount > 0 {
log.Printf("backfill: set account_role=owner on %d customer_users", res.ModifiedCount)
}
// Pass 2: phase 2 created cloud instances and their owners without an
// instance_members row, because the collection did not exist. Reconstruct
// one per instance from the control-plane owner it actually created.
cur, err := db.Admin("admin_instances").Find(ctx, bson.M{
"deployment": license.DeploymentCloud,
"status": bson.M{"$ne": StatusDeleted},
})
if err != nil {
return err
}
var instances []Instance
if err := cur.All(ctx, &instances); err != nil {
return err
}
created := 0
for _, inst := range instances {
n, err := db.Admin("instance_members").CountDocuments(ctx,
bson.M{"instance_id": inst.InstanceID})
if err != nil {
return err
}
if n > 0 {
continue
}
// Only an hq-sourced owner can be reconstructed: a control-plane owner
// with no hq_user_id was created inside the instance and belongs to
// nobody on this side. Leaving it unrecorded is correct.
var owner sharedmodels.User
err = db.Control("users").FindOne(ctx, bson.M{
"instance_id": inst.InstanceID,
"role": sharedmodels.RoleOwner,
"hq_user_id": bson.M{"$nin": bson.A{nil, ""}},
}).Decode(&owner)
if err != nil {
if err != mongo.ErrNoDocuments {
return err
}
log.Printf("backfill: instance %s has no hq-sourced owner; left unrecorded", inst.InstanceID)
continue
}
if _, err := db.Admin("instance_members").InsertOne(ctx, InstanceMember{
MemberID: uuid.NewString(),
AccountID: inst.AccountID,
InstanceID: inst.InstanceID,
CustomerUserID: owner.HQUserID,
ControlUserID: owner.UserID,
Role: sharedmodels.RoleOwner,
Email: owner.Email,
CreatedAt: time.Now().UTC(),
}); err != nil {
return err
}
created++
}
if created > 0 {
log.Printf("backfill: recorded %d pre-existing instance owners", created)
}
return nil
}
+60
View File
@@ -0,0 +1,60 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Account roles.
//
// Deliberately the same three words as the control plane's own roles rather
// than a second vocabulary: a customer who reads "admin" in the portal and
// "admin" in their instance should not have to learn that they mean different
// things. They govern different scopes — this one governs the HQ account —
// but they mean the same thing about power.
//
// Billing stays owner-only. Owners and admins may invite people, create
// instances and grant instance access.
const (
AccountRoleOwner = "owner"
AccountRoleAdmin = "admin"
AccountRoleMember = "member"
)
func ValidAccountRole(r string) bool {
switch r {
case AccountRoleOwner, AccountRoleAdmin, AccountRoleMember:
return true
}
return false
}
// AccountRoleAtLeastAdmin is the single definition of "may manage people and
// instances". Every guard calls this rather than comparing strings, so widening
// the rule is one edit.
func AccountRoleAtLeastAdmin(r string) bool {
return r == AccountRoleOwner || r == AccountRoleAdmin
}
// InstanceMember records that one HQ person holds a projected user inside one
// cloud instance.
//
// It is admin's index of the projection, not the authority: the control-plane
// `users` row IS the access. This row exists so the portal can list who is on
// an instance without reading the control plane, and so a password change can
// find every row to update without scanning every instance.
//
// ControlUserID is the projected users.user_id. Role is the role that user
// holds INSIDE the instance, which is not the person's account role.
type InstanceMember struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
MemberID string `bson:"member_id" json:"member_id"`
AccountID string `bson:"account_id" json:"account_id"`
InstanceID string `bson:"instance_id" json:"instance_id"`
CustomerUserID string `bson:"customer_user_id" json:"customer_user_id"`
ControlUserID string `bson:"control_user_id" json:"control_user_id"`
Role string `bson:"role" json:"role"`
Email string `bson:"email" json:"email"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
+11 -4
View File
@@ -155,19 +155,26 @@ type StaffUser struct {
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
// CustomerUser is a self-hosted customer's login. Cloud customers do not have
// one — they authenticate against the control plane with credentials they
// already hold.
// CustomerUser is one person on an HQ account.
//
// AccountRole governs what they may do to the ACCOUNT — invite people, create
// instances, grant access. It says nothing about what they may do inside any
// instance; that is the role on their InstanceMember row.
type CustomerUser struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
UserID string `bson:"user_id" json:"user_id"`
AccountID string `bson:"account_id" json:"account_id"`
Email string `bson:"email" json:"email"`
PasswordHash string `bson:"password_hash" json:"-"`
AccountRole string `bson:"account_role" json:"account_role"`
VerifiedAt *time.Time `bson:"verified_at,omitempty" json:"verified_at,omitempty"`
VerifyTokenHash string `bson:"verify_token_hash,omitempty" json:"-"`
VerifyTokenExpiry *time.Time `bson:"verify_token_expiry,omitempty" json:"-"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
// HQSyncFailedAt is set when a password change could not be written to
// every projected control-plane row. It is visibility only — hqsync repairs
// by comparing hashes, not by reading this field.
HQSyncFailedAt *time.Time `bson:"hq_sync_failed_at,omitempty" json:"hq_sync_failed_at,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
type AuditEntry struct {
@@ -6,6 +6,7 @@ import { useState } from "react";
import { API_BASE, ApiError, NotConnected, api } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { LicenceDelivery } from "@/components/LicenceDelivery";
import { MembersPanel } from "@/components/MembersPanel";
import { RelinkPanel } from "@/components/RelinkPanel";
import { StatePill } from "@/components/StatePill";
import { formatDate, licenceState, limitLabel } from "@/lib/format";
@@ -88,6 +89,15 @@ export default function InstancePage() {
) : (
<p className="text-ink-2">No licence has been issued for this instance yet.</p>
)}
{instance.deployment === "cloud" ? (
<MembersPanel instanceId={instance.instance_id} />
) : (
<p className="rounded border border-rule bg-panel p-5 text-ink-2">
Users for this install are managed inside it, in Settings Instance. We do not
have access to your own deployment.
</p>
)}
</div>
);
}
@@ -53,8 +53,8 @@ export function CreateForm() {
/>
<p className="text-[0.82rem] text-ink-2">
You sign in to it with this same email address and password. Changing one does not
change the other afterwards.
You sign in to it with this same email address and password. Changing your Vantage
HQ password changes it here too.
</p>
<Button type="submit" disabled={create.isPending || !name.trim()}>
+6
View File
@@ -17,6 +17,12 @@ export default function CustomerLayout({ children }: { children: React.ReactNode
<Link href="/billing" className="text-ink-3">
Billing
</Link>
<Link href="/users" className="text-ink-3">
People
</Link>
<Link href="/settings" className="text-ink-3">
Settings
</Link>
</div>
</nav>
<main className="mx-auto max-w-rail px-5 py-8">{children}</main>
@@ -0,0 +1,75 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { ApiError, api } from "@/lib/api";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
export default function SettingsPage() {
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState<string | null>(null);
const change = useMutation({
mutationFn: () => api.changePassword(current, next),
onSuccess: (res) => {
setCurrent("");
setNext("");
setDone(
res.propagation_pending
? "Password changed. One of your instances could not be updated just now; it will catch up within fifteen minutes."
: "Password changed everywhere.",
);
},
onError: (e) =>
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
});
return (
<div className="grid gap-8">
<header className="grid gap-2">
<h1 className="text-3xl">Settings</h1>
<p className="text-ink-2">
Your password signs you in here and into every Vantage instance you belong to.
Changing it changes all of them.
</p>
</header>
<form
className="grid max-w-md gap-4 rounded border border-rule bg-panel p-5"
onSubmit={(e) => {
e.preventDefault();
setError(null);
setDone(null);
change.mutate();
}}
>
<Field
label="Current password"
type="password"
autoComplete="current-password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
required
/>
<Field
label="New password"
type="password"
autoComplete="new-password"
value={next}
onChange={(e) => setNext(e.target.value)}
required
minLength={12}
hint="At least 12 characters."
error={error ?? undefined}
/>
{done && <p className="text-[0.9rem] text-valid">{done}</p>}
<Button type="submit" disabled={change.isPending || next.length < 12}>
{change.isPending ? "Changing…" : "Change password"}
</Button>
</form>
</div>
);
}
@@ -0,0 +1,173 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { API_BASE, ApiError, NotConnected, api, type AccountRole } from "@/lib/api";
import { useSession } from "@/lib/session";
import { NotConnectedPanel } from "@/components/NotConnected";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
const ROLES: AccountRole[] = ["owner", "admin", "member"];
export function InvitePanel() {
const qc = useQueryClient();
const { session } = useSession();
const [email, setEmail] = useState("");
const [role, setRole] = useState<AccountRole>("member");
const [error, setError] = useState<string | null>(null);
const users = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers });
const refresh = () => qc.invalidateQueries({ queryKey: ["account-users"] });
const fail = (e: unknown) =>
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");
const invite = useMutation({
mutationFn: () => api.invite(email.trim().toLowerCase(), role),
onSuccess: () => {
setEmail("");
setRole("member");
refresh();
},
onError: fail,
});
const setRoleFor = useMutation({
mutationFn: (v: { id: string; role: AccountRole }) => api.setAccountRole(v.id, v.role),
onSuccess: refresh,
onError: fail,
});
const remove = useMutation({
mutationFn: (id: string) => api.removeAccountUser(id),
onSuccess: refresh,
onError: fail,
});
if (users.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
const myRole = session?.account_role;
const canManage = myRole === "owner" || myRole === "admin";
const assignable = myRole === "owner" ? ROLES : ROLES.filter((r) => r !== "owner");
return (
<div className="grid gap-6">
{error && (
<p className="rounded border border-expired bg-panel p-3 text-[0.9rem] text-expired">
{error}
</p>
)}
<table className="w-full border-collapse text-left text-[0.9rem]">
<thead>
<tr className="border-b border-rule font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
<th className="py-2">Email</th>
<th className="py-2">Account role</th>
<th className="py-2">Status</th>
<th className="py-2" />
</tr>
</thead>
<tbody>
{(users.data ?? []).map((u) => {
const isSelf = u.email === session?.email;
return (
<tr key={u.user_id} className="border-b border-rule-soft">
<td className="py-2.5">
{u.email}
{isSelf && <span className="ml-2 text-ink-3">(you)</span>}
</td>
<td className="py-2.5">
{canManage && !isSelf ? (
<select
value={u.account_role}
onChange={(e) =>
setRoleFor.mutate({
id: u.user_id,
role: e.target.value as AccountRole,
})
}
className="rounded border border-rule bg-panel-2 px-2 py-1 font-mono text-[0.82rem] text-ink"
>
{assignable.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
) : (
<span className="font-mono text-[0.82rem]">
{u.account_role}
</span>
)}
</td>
<td className="py-2.5 text-ink-2">
{u.verified_at ? "Active" : "Invitation pending"}
</td>
<td className="py-2.5 text-right">
{canManage && !isSelf && (
<button
type="button"
className="text-[0.82rem] font-semibold text-expired underline"
onClick={() => {
if (
confirm(
`Remove ${u.email}? They lose access to every instance on this account.`,
)
)
remove.mutate(u.user_id);
}}
>
Remove
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
{canManage && (
<form
className="grid max-w-md gap-4 rounded border border-rule bg-panel p-5"
onSubmit={(e) => {
e.preventDefault();
setError(null);
if (email.trim()) invite.mutate();
}}
>
<h2 className="text-xl">Invite someone</h2>
<Field
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
hint="They choose their own password from the emailed link. Nothing happens until they open it."
/>
<label className="grid max-w-md gap-1.5">
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
Account role
</span>
<select
value={role}
onChange={(e) => setRole(e.target.value as AccountRole)}
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
>
{assignable.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</label>
<p className="text-[0.82rem] text-ink-2">
An account role is not access to an instance. Give them that on the
instance itself.
</p>
<Button type="submit" disabled={invite.isPending || !email.trim()}>
{invite.isPending ? "Sending…" : "Send invitation"}
</Button>
</form>
)}
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { InvitePanel } from "./InvitePanel";
export default function UsersPage() {
return (
<div className="grid gap-8">
<header className="grid gap-2">
<h1 className="text-3xl">People</h1>
<p className="text-ink-2">
Everyone on this account. Owners and admins can invite people and grant them
access to instances; billing stays with owners.
</p>
</header>
<InvitePanel />
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Suspense, useState } from "react";
import { ApiError, api } from "@/lib/api";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";
function AcceptForm() {
const token = useSearchParams().get("token") ?? "";
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
const accept = useMutation({
mutationFn: () => api.acceptInvite(token, password),
onSuccess: () => setDone(true),
onError: (e) =>
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
});
if (!token) return <p className="text-ink-2">That link is missing its token.</p>;
if (done)
return (
<div className="grid gap-3">
<h1 className="text-3xl">You&apos;re in</h1>
<p className="text-ink-2">Sign in with your email address and new password.</p>
<Link href="/login" className="font-semibold text-accent underline">
Sign in
</Link>
</div>
);
return (
<form
className="grid max-w-md gap-4"
onSubmit={(e) => {
e.preventDefault();
setError(null);
accept.mutate();
}}
>
<h1 className="text-3xl">Choose a password</h1>
<p className="text-ink-2">
This password signs you into Vantage HQ and into every instance you are given
access to. Nobody who invited you can see it.
</p>
<Field
label="New password"
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={12}
hint="At least 12 characters."
error={error ?? undefined}
/>
<Button type="submit" disabled={accept.isPending || password.length < 12}>
{accept.isPending ? "Setting…" : "Set password"}
</Button>
</form>
);
}
export default function AcceptInvitePage() {
return (
<main className="mx-auto max-w-rail px-5 py-16">
<Suspense fallback={<p className="text-ink-3">Loading</p>}>
<AcceptForm />
</Suspense>
</main>
);
}
+14 -2
View File
@@ -1,12 +1,13 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useSearchParams } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Suspense } from "react";
import { Suspense, useEffect } from "react";
import { api } from "@/lib/api";
function Verify() {
const router = useRouter();
const token = useSearchParams().get("token") ?? "";
const { data, error, isLoading } = useQuery({
queryKey: ["verify", token],
@@ -15,6 +16,17 @@ function Verify() {
retry: false,
});
// An invitation and a verification link are the same shape, and someone will
// paste one into the other. The backend leaves an invite token unspent and
// says so; send them where they can actually finish.
const needsPassword = data?.needs_password === true;
useEffect(() => {
if (needsPassword) {
router.replace(`/accept-invite?token=${encodeURIComponent(token)}`);
}
}, [needsPassword, token, router]);
if (needsPassword) return <Message title="One moment…" body="Taking you to set a password." />;
if (!token)
return (
<Message
+178
View File
@@ -0,0 +1,178 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { ApiError, api, type InstanceRole } from "@/lib/api";
import { useSession } from "@/lib/session";
import { Button } from "@/components/Button";
const ROLES: InstanceRole[] = ["owner", "admin", "member"];
/*
* Absent entirely for self-hosted instances — the backend refuses those, and a
* panel that renders controls the server will reject is a panel that lies.
*/
export function MembersPanel({ instanceId }: { instanceId: string }) {
const qc = useQueryClient();
const { session } = useSession();
const [selected, setSelected] = useState("");
const [role, setRole] = useState<InstanceRole>("member");
const [error, setError] = useState<string | null>(null);
const members = useQuery({
queryKey: ["members", instanceId],
queryFn: () => api.members(instanceId),
});
const people = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers });
const refresh = () => qc.invalidateQueries({ queryKey: ["members", instanceId] });
const fail = (e: unknown) =>
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");
const grant = useMutation({
mutationFn: () => api.grantMember(instanceId, selected, role),
onSuccess: () => {
setSelected("");
setRole("member");
refresh();
},
onError: fail,
});
const changeRole = useMutation({
mutationFn: (v: { uid: string; role: InstanceRole }) =>
api.setMemberRole(instanceId, v.uid, v.role),
onSuccess: refresh,
onError: fail,
});
const revoke = useMutation({
mutationFn: (uid: string) => api.revokeMember(instanceId, uid),
onSuccess: refresh,
onError: fail,
});
const myRole = session?.account_role;
const canManage = myRole === "owner" || myRole === "admin";
const granted = new Set((members.data ?? []).map((m) => m.customer_user_id));
const candidates = (people.data ?? []).filter(
(p) => !granted.has(p.user_id) && p.verified_at,
);
const pending = (people.data ?? []).filter((p) => !p.verified_at).length;
return (
<section className="grid gap-4 rounded border border-rule bg-panel p-5">
<div className="grid gap-1">
<h2 className="text-xl">Who can sign in</h2>
<p className="text-[0.82rem] text-ink-2">
Each person here has a real user inside this instance and signs in with their
Vantage HQ password.
</p>
</div>
{error && <p className="text-[0.9rem] text-expired">{error}</p>}
<ul className="grid gap-2">
{(members.data ?? []).map((m) => (
<li
key={m.member_id}
className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft pb-2"
>
<span>{m.email}</span>
<span className="flex items-center gap-3">
{canManage ? (
<select
value={m.role}
onChange={(e) =>
changeRole.mutate({
uid: m.customer_user_id,
role: e.target.value as InstanceRole,
})
}
className="rounded border border-rule bg-panel-2 px-2 py-1 font-mono text-[0.82rem] text-ink"
>
{ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
) : (
<span className="font-mono text-[0.82rem]">{m.role}</span>
)}
{canManage && (
<button
type="button"
className="text-[0.82rem] font-semibold text-expired underline"
onClick={() => {
if (confirm(`Remove ${m.email} from this instance?`))
revoke.mutate(m.customer_user_id);
}}
>
Remove
</button>
)}
</span>
</li>
))}
{members.data?.length === 0 && (
<li className="text-ink-2">Nobody has been added yet.</li>
)}
</ul>
{canManage && (
<form
className="flex flex-wrap items-end gap-3"
onSubmit={(e) => {
e.preventDefault();
setError(null);
if (selected) grant.mutate();
}}
>
<label className="grid gap-1.5">
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
Add someone
</span>
<select
value={selected}
onChange={(e) => setSelected(e.target.value)}
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
>
<option value="">Choose a person</option>
{candidates.map((p) => (
<option key={p.user_id} value={p.user_id}>
{p.email}
</option>
))}
</select>
</label>
<label className="grid gap-1.5">
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
Role here
</span>
<select
value={role}
onChange={(e) => setRole(e.target.value as InstanceRole)}
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
>
{ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</label>
<Button type="submit" disabled={!selected || grant.isPending}>
{grant.isPending ? "Adding…" : "Add"}
</Button>
</form>
)}
{canManage && pending > 0 && (
<p className="text-[0.82rem] text-ink-3">
{pending} invited {pending === 1 ? "person has" : "people have"} not accepted
yet and cannot be added until they do.
</p>
)}
</section>
);
}
+63 -1
View File
@@ -53,16 +53,52 @@ async function req<T>(path: string, init?: RequestInit): Promise<T> {
const post = <T,>(path: string, payload?: unknown) =>
req<T>(path, { method: "POST", body: payload ? JSON.stringify(payload) : undefined });
const put = <T,>(path: string, payload?: unknown) =>
req<T>(path, { method: "PUT", body: payload ? JSON.stringify(payload) : undefined });
const del = <T,>(path: string) => req<T>(path, { method: "DELETE" });
// --- types ---------------------------------------------------------------
export type Deployment = "cloud" | "self_hosted";
export type Tier = "free" | "professional" | "self_hosted";
export type InstanceStatus = "awaiting_link" | "active" | "lapsed" | "cancelled" | "deleted";
/*
* Two role vocabularies, same three words. AccountRole governs the HQ account:
* who may invite, create instances and grant access. InstanceRole is the role a
* projected user holds INSIDE one instance. A person can be an account member
* and an instance owner at once — that is normal, not a mistake.
*/
export type AccountRole = "owner" | "admin" | "member";
export type InstanceRole = "owner" | "admin" | "member";
export interface Session {
kind: "staff" | "customer";
email: string;
account_id?: string;
account_role?: AccountRole;
}
export interface AccountUser {
user_id: string;
account_id: string;
email: string;
account_role: AccountRole;
verified_at?: string | null;
hq_sync_failed_at?: string | null;
created_at: string;
}
export interface InstanceMember {
member_id: string;
account_id: string;
instance_id: string;
customer_user_id: string;
control_user_id: string;
role: InstanceRole;
email: string;
created_at: string;
}
export interface Limits {
@@ -183,7 +219,9 @@ export const api = {
signup: (payload: { name: string; email: string; password: string; website?: string }) =>
post<{ pending: boolean }>("/auth/signup", payload),
verify: (token: string) =>
req<{ verified: boolean }>(`/auth/verify?token=${encodeURIComponent(token)}`),
req<{ verified: boolean; needs_password?: boolean }>(
`/auth/verify?token=${encodeURIComponent(token)}`,
),
account: () => req<AccountResponse>("/api/account"),
link: (instance_id: string, name: string) =>
@@ -196,6 +234,30 @@ export const api = {
licenseBlobUrl: (id: string) => `${API_BASE}/api/instances/${id}/license/download`,
subscriptions: () => req<Subscription[]>("/api/subscriptions"),
accountUsers: () => req<AccountUser[]>("/api/account/users"),
invite: (email: string, role: AccountRole) =>
post<{ invited: boolean }>("/api/account/users", { email, role }),
setAccountRole: (userId: string, role: AccountRole) =>
put<{ ok: boolean }>(`/api/account/users/${userId}/role`, { role }),
removeAccountUser: (userId: string) =>
del<{ deleted: boolean }>(`/api/account/users/${userId}`),
changePassword: (current_password: string, new_password: string) =>
put<{ updated: boolean; propagation_pending: boolean }>("/api/account/password", {
current_password,
new_password,
}),
acceptInvite: (token: string, password: string) =>
post<{ accepted: boolean }>("/auth/accept-invite", { token, password }),
members: (instanceId: string) =>
req<InstanceMember[]>(`/api/instances/${instanceId}/members`),
grantMember: (instanceId: string, user_id: string, role: InstanceRole) =>
post<InstanceMember>(`/api/instances/${instanceId}/members`, { user_id, role }),
setMemberRole: (instanceId: string, userId: string, role: InstanceRole) =>
put<{ ok: boolean }>(`/api/instances/${instanceId}/members/${userId}/role`, { role }),
revokeMember: (instanceId: string, userId: string) =>
del<{ revoked: boolean }>(`/api/instances/${instanceId}/members/${userId}`),
staff: {
accounts: (q?: string) =>
req<Account[]>(`/api/staff/accounts${q ? `?q=${encodeURIComponent(q)}` : ""}`),
+3 -1
View File
@@ -111,7 +111,9 @@ func deleteInstanceUser(c *gin.Context) {
}
func orgUserErrStatus(err error) int {
if errors.Is(err, services.ErrLastOwner) {
// 409 rather than 403: the caller has the right to manage members, and the
// request is refused because of the resource's state, not their permissions.
if errors.Is(err, services.ErrLastOwner) || errors.Is(err, services.ErrHQManaged) {
return http.StatusConflict
}
return http.StatusInternalServerError
+16
View File
@@ -16,6 +16,16 @@ import (
var ErrLastOwner = errors.New("this is the organization's last owner promote another member to owner first")
// ErrHQManaged is returned when a caller tries to change a user this instance
// does not own.
//
// An hq-sourced row is projected from a Vantage HQ account: HQ owns its role,
// its password and its existence. A role editable in two places is a role with
// two answers, and the loser is whichever writer ran first. Refusing here
// rather than merely hiding the control in web/ is the point — the API is the
// boundary, the UI is a courtesy.
var ErrHQManaged = errors.New("this member is managed in Vantage HQ; change their role or remove them from the HQ portal")
func CountUsers() (int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -122,6 +132,9 @@ func UpdateUserRole(instanceID, userID, role string) error {
if err != nil {
return fmt.Errorf("user not found")
}
if target.AuthSource == models.AuthHQ {
return ErrHQManaged
}
if target.Role == models.RoleOwner && role != models.RoleOwner {
others, err := countOtherOwners(instanceID, userID)
@@ -146,6 +159,9 @@ func DeleteUser(instanceID, userID string) error {
if err != nil {
return fmt.Errorf("user not found")
}
if target.AuthSource == models.AuthHQ {
return ErrHQManaged
}
if target.Role == models.RoleOwner {
others, err := countOtherOwners(instanceID, userID)
if err != nil {
+6
View File
@@ -17,6 +17,12 @@ COPY . .
ARG NEXT_PUBLIC_API_URL=http://localhost:8080
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
# Empty default on purpose: a self-hosted install has no HQ portal, and the
# "Managed in Vantage HQ" label falls back to plain text rather than linking
# somewhere that does not serve them.
ARG NEXT_PUBLIC_HQ_URL=
ENV NEXT_PUBLIC_HQ_URL=$NEXT_PUBLIC_HQ_URL
RUN npm run build
# Runtime stage
+33 -12
View File
@@ -69,6 +69,7 @@ function MembersCard() {
const isOwner = user?.role === "owner";
const assignableRoles = isOwner ? ROLES : ROLES.filter((r) => r !== "owner");
const hqUrl = process.env.NEXT_PUBLIC_HQ_URL ?? "";
return (
<Card>
@@ -113,7 +114,10 @@ function MembersCard() {
{users.map((u: InstanceUser) => {
const isSelf = u.user_id === user?.user_id;
const locked = isSelf || (u.role === "owner" && !isOwner);
const managedByHQ = u.auth_source === "hq";
// Locked here is a courtesy: the API returns 409 for an hq-sourced
// role change or deletion whether or not this select is rendered.
const locked = isSelf || managedByHQ || (u.role === "owner" && !isOwner);
return (
<Tr key={u.user_id}>
<Td>
@@ -138,22 +142,39 @@ function MembersCard() {
)}
</Td>
<Td>
<Badge variant="neutral">{u.auth_source === "oidc" ? "SSO" : "Password"}</Badge>
<Badge variant="neutral">
{u.auth_source === "oidc" ? "SSO" : u.auth_source === "hq" ? "Vantage HQ" : "Password"}
</Badge>
</Td>
<Td className="text-text-secondary">
{u.last_login ? new Date(u.last_login).toLocaleString() : "Never"}
</Td>
<Td className="text-right">
{!locked && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm(`Remove ${u.email} from this instance?`)) removeUser(u.user_id);
}}
>
Remove
</Button>
{managedByHQ ? (
hqUrl ? (
<a
href={hqUrl}
target="_blank"
rel="noreferrer"
className="text-xs text-text-secondary underline"
>
Managed in Vantage HQ
</a>
) : (
<span className="text-xs text-text-tertiary">Managed in Vantage HQ</span>
)
) : (
!locked && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm(`Remove ${u.email} from this instance?`)) removeUser(u.user_id);
}}
>
Remove
</Button>
)
)}
</Td>
</Tr>
+4 -1
View File
@@ -335,7 +335,10 @@ export interface InstanceUser {
instance_id: string;
email: string;
role: Role;
auth_source: "local" | "oidc";
// "hq" means the row was projected from a Vantage HQ account. Its role,
// password and existence belong to HQ; this instance refuses to change them.
auth_source: "local" | "oidc" | "hq";
hq_user_id?: string;
created_at: string;
last_login?: string;
}