Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
# Go service. Build + vet each independently against published core.
module:
- "."
- "module/services/api/code"
- "module/services/accounts/code"
- "module/services/auth-sidecar/code"
steps:
- uses: actions/checkout@v4
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,12 @@ func (h *teamConnectHandler) RemoveMember(ctx context.Context, req *connect.Requ
func (h *teamConnectHandler) ListMembers(ctx context.Context, req *connect.Request[gen.ListTeamMembersRequest]) (*connect.Response[gen.ListTeamMembersResponse], error) {
return unary(ctx, req, h.inner.ListMembers)
}
func (h *teamConnectHandler) UpdateTeam(ctx context.Context, req *connect.Request[gen.UpdateTeamRequest]) (*connect.Response[gen.UpdateTeamResponse], error) {
return unary(ctx, req, h.inner.UpdateTeam)
}
func (h *teamConnectHandler) DeleteTeam(ctx context.Context, req *connect.Request[gen.DeleteTeamRequest]) (*connect.Response[emptypb.Empty], error) {
return unary(ctx, req, h.inner.DeleteTeam)
}

// ============================================================================
// PermissionService
Expand Down Expand Up @@ -400,6 +406,9 @@ func (h *platformAdminConnectHandler) ImpersonateUser(ctx context.Context, req *
func (h *platformAdminConnectHandler) ListActiveSessions(ctx context.Context, req *connect.Request[gen.ListActiveSessionsRequest]) (*connect.Response[gen.ListActiveSessionsResponse], error) {
return unary(ctx, req, h.inner.ListActiveSessions)
}
func (h *platformAdminConnectHandler) RevokeSession(ctx context.Context, req *connect.Request[gen.RevokeSessionRequest]) (*connect.Response[emptypb.Empty], error) {
return unary(ctx, req, h.inner.RevokeSession)
}
func (h *platformAdminConnectHandler) GetOrgEntitlements(ctx context.Context, req *connect.Request[gen.GetOrgEntitlementsRequest]) (*connect.Response[gen.GetOrgEntitlementsResponse], error) {
return unary(ctx, req, h.inner.GetOrgEntitlements)
}
Expand Down
68 changes: 62 additions & 6 deletions module/services/accounts/code/pkg/adapters/rpcs.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,21 @@ func (s *UserServer) UpdateUser(ctx context.Context, req *gen.UpdateUserRequest)
if err := Validate(req); err != nil {
return nil, err
}
w := wool.Get(ctx).In("UpdateUser")
w.GRPC().Inject()
userID, found := w.UserAuthID()
if !found {
return nil, status.Error(codes.Unauthenticated, "user id not found")
actorID, err := requireAuth(ctx)
if err != nil {
return nil, err
}
// The target is the request's uuid (validated). The caller must be that user
// (self-service profile edit) or a platform admin (the admin Users table) —
// the same gate DeleteUser uses. Previously this ignored req.Uuid and updated
// the caller, so an admin could never edit another user.
if err := requireSelfOrPlatformAdmin(ctx, actorID, req.Uuid); err != nil {
return nil, err
}
if err := requireScope(ctx, "users:write"); err != nil {
return nil, err
}
return service.UpdateUser(ctx, userID, req)
return service.UpdateUser(ctx, req.Uuid, req)
}

func (s *UserServer) DeleteUser(ctx context.Context, req *gen.GetUserRequest) (*emptypb.Empty, error) {
Expand Down Expand Up @@ -358,6 +363,41 @@ func (s *TeamServer) ListMembers(ctx context.Context, req *gen.ListTeamMembersRe
return service.ListTeamMembers(ctx, req)
}

func (s *TeamServer) UpdateTeam(ctx context.Context, req *gen.UpdateTeamRequest) (*gen.UpdateTeamResponse, error) {
if err := Validate(req); err != nil {
return nil, err
}
actorID, err := requireAuth(ctx)
if err != nil {
return nil, err
}
orgID, err := requireTeamAdmin(ctx, actorID, req.TeamId)
if err != nil {
return nil, err
}
ctx = business.WithCachedTeamOrgID(ctx, req.TeamId, orgID)
return service.UpdateTeam(ctx, actorID, req)
}

func (s *TeamServer) DeleteTeam(ctx context.Context, req *gen.DeleteTeamRequest) (*emptypb.Empty, error) {
if err := Validate(req); err != nil {
return nil, err
}
actorID, err := requireAuth(ctx)
if err != nil {
return nil, err
}
orgID, err := requireTeamAdmin(ctx, actorID, req.TeamId)
if err != nil {
return nil, err
}
ctx = business.WithCachedTeamOrgID(ctx, req.TeamId, orgID)
if err := service.DeleteTeam(ctx, actorID, req); err != nil {
return nil, err
}
return &emptypb.Empty{}, nil
}

// ============================================================================
// PermissionService RPCs (on PermServer)
// ============================================================================
Expand Down Expand Up @@ -848,6 +888,22 @@ func (s *PlatformAdminServer) ListActiveSessions(ctx context.Context, req *gen.L
return service.ListActiveSessions(ctx, actorID, req)
}

func (s *PlatformAdminServer) RevokeSession(ctx context.Context, req *gen.RevokeSessionRequest) (*emptypb.Empty, error) {
if err := Validate(req); err != nil {
return nil, err
}
w := wool.Get(ctx).In("RevokeSession")
w.GRPC().Inject()
actorID, found := w.UserAuthID()
if !found {
return nil, status.Error(codes.Unauthenticated, "user id not found")
}
if err := service.RevokeSession(ctx, actorID, req); err != nil {
return nil, err
}
return &emptypb.Empty{}, nil
}

func (s *PlatformAdminServer) GetOrgEntitlements(ctx context.Context, req *gen.GetOrgEntitlementsRequest) (*gen.GetOrgEntitlementsResponse, error) {
if err := Validate(req); err != nil {
return nil, err
Expand Down
8 changes: 7 additions & 1 deletion module/services/accounts/code/pkg/business/consent.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,13 @@ func (s *Service) GetConsentStatus(ctx context.Context, userID string) (*UserCon
// version the user actually saw is more honest than refusing the
// click. Next page load they'll see the new version and accept it.
func (s *Service) AcceptConsent(ctx context.Context, userID, version string) error {
if err := s.store.SetUserConsent(ctx, userID, version, time.Now()); err != nil {
// The write targets the caller's own users row, which is RLS-protected
// (users_update: uuid == app.current_user_id). Scope the tx to the user so
// the GUC is set — without this the UPDATE matches zero rows under the
// app_tenant role and consent silently never persists (banner reappears).
if err := s.store.As(Identity{UserID: userID}).Within(ctx, func(ctx context.Context) error {
return s.store.SetUserConsent(ctx, userID, version, time.Now())
}); err != nil {
return err
}
s.emit(ctx, userID, "user", "consent.accepted", "user", userID, "")
Expand Down
24 changes: 24 additions & 0 deletions module/services/accounts/code/pkg/business/platform_admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,30 @@ func (s *Service) ListActiveSessions(ctx context.Context, actorID string, req *g
return &gen.ListActiveSessionsResponse{Sessions: infos}, nil
}

// RevokeSession force-logs-out one active session by id (support+ only). A platform
// admin acts across all users, so the write rides WithBypass (RLS would otherwise scope
// the sessions table to the caller).
func (s *Service) RevokeSession(ctx context.Context, actorID string, req *gen.RevokeSessionRequest) error {
w := wool.Get(ctx).In("RevokeSession")

if err := s.requirePlatformRole(ctx, actorID, "support"); err != nil {
return w.Wrapf(err, "permission denied")
}

reason := req.Reason
if reason == "" {
reason = "revoked_by_admin"
}
if err := s.store.WithBypass(ctx, func(ctx context.Context) error {
return s.store.RevokeSession(ctx, req.SessionId, reason)
}); err != nil {
return w.Wrapf(err, "cannot revoke session")
}

s.emit(ctx, actorID, "user", "session.revoked", "session", req.SessionId, "")
return nil
}

// GrantPlatformRole grants a platform role to a user (super_admin only).
func (s *Service) GrantPlatformRole(ctx context.Context, actorID string, req *gen.GrantPlatformRoleRequest) error {
w := wool.Get(ctx).In("GrantPlatformRole")
Expand Down
2 changes: 2 additions & 0 deletions module/services/accounts/code/pkg/business/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ type Store interface {
// Teams
CreateTeam(ctx context.Context, team *gen.Team) error
ListTeams(ctx context.Context, orgID string) ([]*gen.Team, error)
UpdateTeam(ctx context.Context, teamID, name, description string) (*gen.Team, error)
DeleteTeam(ctx context.Context, teamID string) error
AddTeamMember(ctx context.Context, teamID string, userID string, role string) error
RemoveTeamMember(ctx context.Context, teamID string, userID string) error
ListTeamMembers(ctx context.Context, teamID string) ([]*gen.TeamMembership, error)
Expand Down
41 changes: 41 additions & 0 deletions module/services/accounts/code/pkg/business/teams.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,47 @@ func (s *Service) RemoveTeamMember(ctx context.Context, actorID string, req *gen
return nil
}

// UpdateTeam renames / re-describes a team. Org-scoped like the other team writes.
func (s *Service) UpdateTeam(ctx context.Context, actorID string, req *gen.UpdateTeamRequest) (*gen.UpdateTeamResponse, error) {
w := wool.Get(ctx).In("UpdateTeam")

orgID, err := s.resolveTeamOrg(ctx, req.TeamId)
if err != nil {
return nil, w.Wrapf(err, "cannot resolve team org")
}

var team *gen.Team
if err := s.store.WithOrgTx(ctx, orgID, func(ctx context.Context) error {
t, err := s.store.UpdateTeam(ctx, req.TeamId, req.Name, req.Description)
team = t
return err
}); err != nil {
return nil, w.Wrapf(err, "cannot update team")
}

s.emit(ctx, actorID, "user", "team.updated", "team", req.TeamId, orgID)
return &gen.UpdateTeamResponse{Team: team}, nil
}

// DeleteTeam removes a team (and its memberships). Org-scoped.
func (s *Service) DeleteTeam(ctx context.Context, actorID string, req *gen.DeleteTeamRequest) error {
w := wool.Get(ctx).In("DeleteTeam")

orgID, err := s.resolveTeamOrg(ctx, req.TeamId)
if err != nil {
return w.Wrapf(err, "cannot resolve team org")
}

if err := s.store.WithOrgTx(ctx, orgID, func(ctx context.Context) error {
return s.store.DeleteTeam(ctx, req.TeamId)
}); err != nil {
return w.Wrapf(err, "cannot delete team")
}

s.emit(ctx, actorID, "user", "team.deleted", "team", req.TeamId, orgID)
return nil
}

// ListTeamMembers lists all members of a team.
func (s *Service) ListTeamMembers(ctx context.Context, req *gen.ListTeamMembersRequest) (*gen.ListTeamMembersResponse, error) {
w := wool.Get(ctx).In("ListTeamMembers")
Expand Down
7 changes: 6 additions & 1 deletion module/services/accounts/code/pkg/business/user_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,12 @@ func (s *Service) UpdateUserSettings(ctx context.Context, userID string, patch *
if err != nil {
return nil, fmt.Errorf("encode patch: %w", err)
}
if err := s.store.UpdateUserSettings(ctx, userID, body); err != nil {
// Settings live on the RLS-protected users row; scope the tx to the user so
// app.current_user_id is set (else the UPDATE silently matches zero rows and
// settings never persist — same class of bug as consent).
if err := s.store.As(Identity{UserID: userID}).Within(ctx, func(ctx context.Context) error {
return s.store.UpdateUserSettings(ctx, userID, body)
}); err != nil {
return nil, err
}
s.emit(ctx, userID, "user", "settings.updated", "user", userID, "")
Expand Down
26 changes: 20 additions & 6 deletions module/services/accounts/code/pkg/business/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,16 @@ func (s *Service) UpdateUser(ctx context.Context, userID string, req *gen.Update
return s.store.GetUser(ctx, userID)
}

user, err := s.store.UpdateUser(ctx, userID, updates)
if err != nil {
// The users row is RLS-protected (users_update: uuid == app.current_user_id).
// Scope to the TARGET user so the GUC is set — this permits both the self-edit
// and the admin-edits-another case (the row being updated IS the scoped id).
// Without it the UPDATE silently matches zero rows under the app_tenant role.
var user *gen.User
if err := s.store.As(Identity{UserID: userID}).Within(ctx, func(ctx context.Context) error {
u, e := s.store.UpdateUser(ctx, userID, updates)
user = u
return e
}); err != nil {
return nil, w.Wrapf(err, "cannot update user")
}

Expand All @@ -120,13 +128,19 @@ func (s *Service) DeleteUser(ctx context.Context, userID string, req *gen.GetUse
if targetID == "" {
return w.NewError("uuid required for delete")
}
if err := s.store.DeleteUser(ctx, targetID); err != nil {
// Scope to the target so the RLS users_delete policy (uuid == app.current_user_id)
// permits the soft-delete; the session revoke rides the same scope.
if err := s.store.As(Identity{UserID: targetID}).Within(ctx, func(ctx context.Context) error {
if err := s.store.DeleteUser(ctx, targetID); err != nil {
return err
}
// Revoke all sessions (best-effort)
_ = s.store.RevokeAllUserSessions(ctx, targetID, "user_deleted")
return nil
}); err != nil {
return w.Wrapf(err, "cannot delete user")
}

// Revoke all sessions
_ = s.store.RevokeAllUserSessions(ctx, targetID, "user_deleted")

s.emit(ctx, userID, "user", "user.deleted", "user", targetID, "")
return nil
}
Expand Down
Loading
Loading