Truncate access review roles with badge list

Long role strings in the access review table broke row layout when
drivers joined many roles into one comma-separated value. Expose
roles as a string array in GraphQL by splitting the stored role at
the API layer, and render the first three roles as badges with a
"+X more" popover for the rest.

Closes ENG-459.

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
Ludovic Vielle
2026-06-12 18:23:04 +02:00
parent bf20ca1a90
commit 8094e7cfd0
80 changed files with 768 additions and 378 deletions

View File

@@ -0,0 +1,88 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { Badge, Td } from "@probo/ui";
import * as Popover from "@radix-ui/react-popover";
import { graphql, useFragment } from "react-relay";
import type { AccessEntryRolesCell_accessEntry$key } from "#/__generated__/core/AccessEntryRolesCell_accessEntry.graphql";
import { NotAvailable } from "./accessReviewHelpers";
const VISIBLE_ROLE_COUNT = 3;
const accessEntryRolesCellFragment = graphql`
fragment AccessEntryRolesCell_accessEntry on AccessReviewEntry {
roles
}
`;
type Props = {
accessEntryKey: AccessEntryRolesCell_accessEntry$key;
};
export function AccessEntryRolesCell({ accessEntryKey }: Props) {
const entry = useFragment(accessEntryRolesCellFragment, accessEntryKey);
const roles = entry.roles;
if (roles.length === 0) {
return (
<Td className="max-w-xs">
<NotAvailable />
</Td>
);
}
const visibleRoles = roles.slice(0, VISIBLE_ROLE_COUNT);
const hiddenRoles = roles.slice(VISIBLE_ROLE_COUNT);
return (
<Td noLink className="max-w-xs">
<div className="flex flex-wrap gap-1">
{visibleRoles.map((role, index) => (
<Badge key={`${index}-${role}`} variant="neutral" className="text-xs">
{role}
</Badge>
))}
{hiddenRoles.length > 0 && (
<Popover.Root>
<Popover.Trigger asChild>
<button type="button" className="inline-flex">
<Badge variant="neutral" className="text-xs cursor-pointer">
+
{hiddenRoles.length}
</Badge>
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
className="z-50 rounded-md border bg-level-0 p-3 shadow-md max-w-sm"
sideOffset={4}
align="start"
>
<div className="flex flex-wrap gap-1">
{hiddenRoles.map((role, index) => (
<Badge key={`${index}-${role}`} variant="neutral" className="text-xs">
{role}
</Badge>
))}
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
)}
</div>
</Td>
);
}

View File

@@ -56,6 +56,7 @@ import type { CampaignDetailPageQuery } from "#/__generated__/core/CampaignDetai
import type { CampaignDetailPageStartMutation } from "#/__generated__/core/CampaignDetailPageStartMutation.graphql"; import type { CampaignDetailPageStartMutation } from "#/__generated__/core/CampaignDetailPageStartMutation.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId"; import { useOrganizationId } from "#/hooks/useOrganizationId";
import { AccessEntryRolesCell } from "../_components/AccessEntryRolesCell";
import { import {
decisionBadgeVariant, decisionBadgeVariant,
decisionLabel, decisionLabel,
@@ -169,7 +170,7 @@ export const campaignDetailPageQuery = graphql`
id id
email email
fullName fullName
role ...AccessEntryRolesCell_accessEntry
isAdmin isAdmin
active active
mfaStatus mfaStatus
@@ -721,7 +722,7 @@ function CampaignSourceCard({ source, isPendingActions }: { source: CampaignSour
</span> </span>
</Td> </Td>
<Td>{edge.node.email || <NotAvailable />}</Td> <Td>{edge.node.email || <NotAvailable />}</Td>
<Td>{edge.node.role || <NotAvailable />}</Td> <AccessEntryRolesCell accessEntryKey={edge.node} />
<Td>{edge.node.isAdmin ? __("Yes") : __("No")}</Td> <Td>{edge.node.isAdmin ? __("Yes") : __("No")}</Td>
<Td> <Td>
{edge.node.active == null {edge.node.active == null

View File

@@ -56,7 +56,7 @@ const addScopeMutation = graphql`
id id
email email
fullName fullName
role roles
isAdmin isAdmin
mfaStatus mfaStatus
lastLogin lastLogin

View File

@@ -71,7 +71,7 @@ func (d *AnthropicDriver) ListAccounts(ctx context.Context) ([]AccountRecord, er
record := AccountRecord{ record := AccountRecord{
Email: u.Email, Email: u.Email,
FullName: u.Name, FullName: u.Name,
Role: anthropicRole(u.Role), Roles: anthropicRoles(u.Role),
IsAdmin: u.Role == "admin", IsAdmin: u.Role == "admin",
ExternalID: u.ID, ExternalID: u.ID,
MFAStatus: coredata.MFAStatusUnknown, MFAStatus: coredata.MFAStatusUnknown,
@@ -141,19 +141,23 @@ func (d *AnthropicDriver) fetchUsers(ctx context.Context, afterID string) (*anth
return &resp, nil return &resp, nil
} }
func anthropicRole(role string) string { func anthropicRoles(role string) []string {
if role == "" {
return []string{}
}
switch role { switch role {
case "admin": case "admin":
return "Admin" return []string{"Admin"}
case "billing": case "billing":
return "Billing" return []string{"Billing"}
case "developer": case "developer":
return "Developer" return []string{"Developer"}
case "claude_code_user": case "claude_code_user":
return "Claude Code User" return []string{"Claude Code User"}
case "user": case "user":
return "User" return []string{"User"}
default: default:
return role return []string{role}
} }
} }

View File

@@ -39,38 +39,39 @@ func TestAnthropicDriver(t *testing.T) {
assert.NotEmpty(t, first.Email) assert.NotEmpty(t, first.Email)
assert.NotEmpty(t, first.FullName) assert.NotEmpty(t, first.FullName)
assert.NotEmpty(t, first.ExternalID) assert.NotEmpty(t, first.ExternalID)
assert.Equal(t, "User", first.Role) assert.Equal(t, []string{"User"}, first.Roles)
assert.False(t, first.IsAdmin) assert.False(t, first.IsAdmin)
assert.NotNil(t, first.CreatedAt) assert.NotNil(t, first.CreatedAt)
assert.Equal(t, "Developer", records[1].Role) assert.Equal(t, []string{"Developer"}, records[1].Roles)
assert.False(t, records[1].IsAdmin) assert.False(t, records[1].IsAdmin)
admin := records[2] admin := records[2]
assert.Equal(t, "Admin", admin.Role) assert.Equal(t, []string{"Admin"}, admin.Roles)
assert.True(t, admin.IsAdmin) assert.True(t, admin.IsAdmin)
} }
func TestAnthropicRole(t *testing.T) { func TestAnthropicRoles(t *testing.T) {
t.Parallel() t.Parallel()
cases := []struct { cases := []struct {
in string in string
want string want []string
}{ }{
{"admin", "Admin"}, {"admin", []string{"Admin"}},
{"billing", "Billing"}, {"billing", []string{"Billing"}},
{"developer", "Developer"}, {"developer", []string{"Developer"}},
{"claude_code_user", "Claude Code User"}, {"claude_code_user", []string{"Claude Code User"}},
{"user", "User"}, {"user", []string{"User"}},
{"unknown_future_role", "unknown_future_role"}, {"unknown_future_role", []string{"unknown_future_role"}},
{"", []string{}},
} }
for _, c := range cases { for _, c := range cases {
t.Run(c.in, func(t *testing.T) { t.Run(c.in, func(t *testing.T) {
t.Parallel() t.Parallel()
assert.Equal(t, c.want, anthropicRole(c.in)) assert.Equal(t, c.want, anthropicRoles(c.in))
}) })
} }
} }

View File

@@ -94,7 +94,7 @@ func (d *BetterStackDriver) ListAccounts(ctx context.Context) ([]AccountRecord,
record := AccountRecord{ record := AccountRecord{
Email: member.Attributes.Email, Email: member.Attributes.Email,
FullName: strings.TrimSpace(member.Attributes.FirstName + " " + member.Attributes.LastName), FullName: strings.TrimSpace(member.Attributes.FirstName + " " + member.Attributes.LastName),
Role: betterStackRole(member.Attributes.Role), Roles: betterStackRoles(member.Attributes.Role),
Active: betterStackActive(member.Type), Active: betterStackActive(member.Type),
IsAdmin: betterStackIsAdmin(member.Attributes.Role), IsAdmin: betterStackIsAdmin(member.Attributes.Role),
MFAStatus: coredata.MFAStatusUnknown, MFAStatus: coredata.MFAStatusUnknown,
@@ -168,23 +168,27 @@ func (d *BetterStackDriver) fetchTeamMembersPage(
return &resp, nil return &resp, nil
} }
// betterStackRole maps a Better Stack role token to a human-readable label. // betterStackRoles maps a Better Stack role token to a human-readable label.
// Unknown roles (including the Enterprise "custom" roles) are passed through // Unknown roles (including the Enterprise "custom" roles) are passed through
// unchanged so the reviewer still sees the source value. // unchanged so the reviewer still sees the source value.
func betterStackRole(role string) string { func betterStackRoles(role string) []string {
if role == "" {
return []string{}
}
switch role { switch role {
case "admin": case "admin":
return "Admin" return []string{"Admin"}
case "billing_admin": case "billing_admin":
return "Billing admin" return []string{"Billing admin"}
case "team_lead": case "team_lead":
return "Team lead" return []string{"Team lead"}
case "responder": case "responder":
return "Responder" return []string{"Responder"}
case "member": case "member":
return "Member" return []string{"Member"}
default: default:
return role return []string{role}
} }
} }

View File

@@ -46,7 +46,7 @@ func TestBetterStackDriver(t *testing.T) {
r := records[0] r := records[0]
assert.Equal(t, "alice@example.com", r.Email) assert.Equal(t, "alice@example.com", r.Email)
assert.Equal(t, "Alice Smith", r.FullName) assert.Equal(t, "Alice Smith", r.FullName)
assert.Equal(t, "Admin", r.Role) assert.Equal(t, []string{"Admin"}, r.Roles)
assert.True(t, r.IsAdmin) assert.True(t, r.IsAdmin)
assert.Equal(t, "101", r.ExternalID) assert.Equal(t, "101", r.ExternalID)
require.NotNil(t, r.Active) require.NotNil(t, r.Active)
@@ -107,28 +107,29 @@ func TestBetterStackDriverListAccountsError(t *testing.T) {
assert.Contains(t, err.Error(), "unexpected status 401") assert.Contains(t, err.Error(), "unexpected status 401")
} }
func TestBetterStackRole(t *testing.T) { func TestBetterStackRoles(t *testing.T) {
t.Parallel() t.Parallel()
cases := []struct { cases := []struct {
in string in string
want string want []string
isAdmin bool isAdmin bool
}{ }{
{"admin", "Admin", true}, {"admin", []string{"Admin"}, true},
{"billing_admin", "Billing admin", false}, {"billing_admin", []string{"Billing admin"}, false},
{"team_lead", "Team lead", true}, {"team_lead", []string{"Team lead"}, true},
{"responder", "Responder", false}, {"responder", []string{"Responder"}, false},
{"member", "Member", false}, {"member", []string{"Member"}, false},
{"custom", "custom", false}, {"custom", []string{"custom"}, false},
{"future_role", "future_role", false}, {"future_role", []string{"future_role"}, false},
{"", []string{}, false},
} }
for _, c := range cases { for _, c := range cases {
t.Run(c.in, func(t *testing.T) { t.Run(c.in, func(t *testing.T) {
t.Parallel() t.Parallel()
assert.Equal(t, c.want, betterStackRole(c.in)) assert.Equal(t, c.want, betterStackRoles(c.in))
assert.Equal(t, c.isAdmin, betterStackIsAdmin(c.in)) assert.Equal(t, c.isAdmin, betterStackIsAdmin(c.in))
}) })
} }

View File

@@ -19,6 +19,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strings"
"go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/coredata"
) )
@@ -64,10 +65,17 @@ func (d *BrexDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error)
} }
for _, u := range resp.Items { for _, u := range resp.Items {
role := strings.TrimSpace(u.Role)
roles := []string{}
if role != "" {
roles = []string{role}
}
record := AccountRecord{ record := AccountRecord{
Email: u.Email, Email: u.Email,
FullName: u.FirstName + " " + u.LastName, FullName: u.FirstName + " " + u.LastName,
Role: u.Role, Roles: roles,
Active: new(u.Status == "ACTIVE"), Active: new(u.Status == "ACTIVE"),
IsAdmin: false, IsAdmin: false,
ExternalID: u.ID, ExternalID: u.ID,

View File

@@ -101,13 +101,13 @@ func (d *ClickUpDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro
records := make([]AccountRecord, 0, len(resp.Team.Members)) records := make([]AccountRecord, 0, len(resp.Team.Members))
for _, m := range resp.Team.Members { for _, m := range resp.Team.Members {
role := clickupRoleLabel(m.User.Role) roles := clickupRoles(m.User.Role)
isAdmin := m.User.Role == 1 || m.User.Role == 2 isAdmin := m.User.Role == 1 || m.User.Role == 2
record := AccountRecord{ record := AccountRecord{
Email: m.User.Email, Email: m.User.Email,
FullName: m.User.Username, FullName: m.User.Username,
Role: role, Roles: roles,
IsAdmin: isAdmin, IsAdmin: isAdmin,
ExternalID: m.User.ID.String(), ExternalID: m.User.ID.String(),
MFAStatus: coredata.MFAStatusUnknown, MFAStatus: coredata.MFAStatusUnknown,
@@ -134,20 +134,20 @@ func (d *ClickUpDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro
return records, nil return records, nil
} }
// clickupRoleLabel maps ClickUp numeric role codes to human-readable // clickupRoles maps ClickUp numeric role codes to human-readable
// labels. Source: https://clickup.com/api (Team Members endpoint). // labels. Source: https://clickup.com/api (Team Members endpoint).
func clickupRoleLabel(role int) string { func clickupRoles(role int) []string {
switch role { switch role {
case 1: case 1:
return "owner" return []string{"owner"}
case 2: case 2:
return "admin" return []string{"admin"}
case 3: case 3:
return "member" return []string{"member"}
case 4: case 4:
return "guest" return []string{"guest"}
default: default:
return "" return []string{}
} }
} }

View File

@@ -46,5 +46,5 @@ func TestClickUpDriver(t *testing.T) {
assert.NotEmpty(t, r.ExternalID) assert.NotEmpty(t, r.ExternalID)
assert.NotEmpty(t, r.Email) assert.NotEmpty(t, r.Email)
assert.NotEmpty(t, r.FullName) assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.Role) assert.NotEmpty(t, r.Roles)
} }

View File

@@ -21,7 +21,6 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"strings"
"go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/coredata"
) )
@@ -170,9 +169,8 @@ func (d *CloudflareDriver) queryAllMembers(ctx context.Context, accountID string
roles = append(roles, r.Name) roles = append(roles, r.Name)
} }
role := "Member" if len(roles) == 0 {
if len(roles) > 0 { roles = []string{"Member"}
role = strings.Join(roles, ", ")
} }
isAdmin := false isAdmin := false
@@ -192,7 +190,7 @@ func (d *CloudflareDriver) queryAllMembers(ctx context.Context, accountID string
record := AccountRecord{ record := AccountRecord{
Email: m.User.Email, Email: m.User.Email,
FullName: m.User.FirstName + " " + m.User.LastName, FullName: m.User.FirstName + " " + m.User.LastName,
Role: role, Roles: roles,
Active: new(m.Status == "accepted"), Active: new(m.Status == "accepted"),
IsAdmin: isAdmin, IsAdmin: isAdmin,
ExternalID: m.ID, ExternalID: m.ID,

View File

@@ -38,5 +38,5 @@ func TestCloudflareDriver(t *testing.T) {
assert.NotEmpty(t, r.Email) assert.NotEmpty(t, r.Email)
assert.NotEmpty(t, r.FullName) assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.ExternalID) assert.NotEmpty(t, r.ExternalID)
assert.NotEmpty(t, r.Role) assert.NotEmpty(t, r.Roles)
} }

View File

@@ -83,7 +83,14 @@ func (d *CSVDriver) ListAccounts(_ context.Context) ([]AccountRecord, error) {
} }
if idx, ok := colIndex["role"]; ok && idx < len(row) { if idx, ok := colIndex["role"]; ok && idx < len(row) {
record.Role = strings.TrimSpace(row[idx]) role := strings.TrimSpace(row[idx])
roles := []string{}
if role != "" {
roles = []string{role}
}
record.Roles = roles
} }
if idx, ok := colIndex["job_title"]; ok && idx < len(row) { if idx, ok := colIndex["job_title"]; ok && idx < len(row) {

View File

@@ -96,7 +96,7 @@ func (d *CursorDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
records = append(records, AccountRecord{ records = append(records, AccountRecord{
Email: m.Email, Email: m.Email,
FullName: m.Name, FullName: m.Name,
Role: cursorRole(m.Role), Roles: cursorRoles(m.Role),
Active: &active, Active: &active,
IsAdmin: cursorIsAdmin(m.Role), IsAdmin: cursorIsAdmin(m.Role),
MFAStatus: coredata.MFAStatusUnknown, MFAStatus: coredata.MFAStatusUnknown,
@@ -116,15 +116,19 @@ func cursorIsAdmin(role string) bool {
return role == "owner" || role == "free-owner" return role == "owner" || role == "free-owner"
} }
func cursorRole(role string) string { func cursorRoles(role string) []string {
if role == "" {
return []string{}
}
switch role { switch role {
case "owner", "free-owner": case "owner", "free-owner":
return "Owner" return []string{"Owner"}
case "member": case "member":
return "Member" return []string{"Member"}
case "removed": case "removed":
return "Removed" return []string{"Removed"}
default: default:
return role return []string{role}
} }
} }

View File

@@ -40,7 +40,7 @@ func TestCursorDriver(t *testing.T) {
member := records[0] member := records[0]
assert.Equal(t, "jane@example.com", member.Email) assert.Equal(t, "jane@example.com", member.Email)
assert.Equal(t, "Jane Doe", member.FullName) assert.Equal(t, "Jane Doe", member.FullName)
assert.Equal(t, "Member", member.Role) assert.Equal(t, []string{"Member"}, member.Roles)
assert.False(t, member.IsAdmin) assert.False(t, member.IsAdmin)
// The Cursor Admin API returns the member id as a string; it is used // The Cursor Admin API returns the member id as a string; it is used
// verbatim as the stable ExternalID. // verbatim as the stable ExternalID.
@@ -49,7 +49,7 @@ func TestCursorDriver(t *testing.T) {
assert.True(t, *member.Active) assert.True(t, *member.Active)
owner := records[1] owner := records[1]
assert.Equal(t, "Owner", owner.Role) assert.Equal(t, []string{"Owner"}, owner.Roles)
assert.True(t, owner.IsAdmin) assert.True(t, owner.IsAdmin)
require.NotNil(t, owner.Active) require.NotNil(t, owner.Active)
assert.True(t, *owner.Active) assert.True(t, *owner.Active)
@@ -57,7 +57,7 @@ func TestCursorDriver(t *testing.T) {
// A removed member (role "removed", isRemoved true) is still returned, // A removed member (role "removed", isRemoved true) is still returned,
// flagged inactive rather than dropped, per the AccountRecord contract. // flagged inactive rather than dropped, per the AccountRecord contract.
removed := records[2] removed := records[2]
assert.Equal(t, "Removed", removed.Role) assert.Equal(t, []string{"Removed"}, removed.Roles)
assert.False(t, removed.IsAdmin) assert.False(t, removed.IsAdmin)
require.NotNil(t, removed.Active) require.NotNil(t, removed.Active)
assert.False(t, *removed.Active) assert.False(t, *removed.Active)
@@ -66,32 +66,33 @@ func TestCursorDriver(t *testing.T) {
// carry role "removed" while isRemoved is still false. The role alone // carry role "removed" while isRemoved is still false. The role alone
// must mark the account inactive. // must mark the account inactive.
removedByRole := records[3] removedByRole := records[3]
assert.Equal(t, "Removed", removedByRole.Role) assert.Equal(t, []string{"Removed"}, removedByRole.Roles)
assert.False(t, removedByRole.IsAdmin) assert.False(t, removedByRole.IsAdmin)
require.NotNil(t, removedByRole.Active) require.NotNil(t, removedByRole.Active)
assert.False(t, *removedByRole.Active) assert.False(t, *removedByRole.Active)
} }
func TestCursorRole(t *testing.T) { func TestCursorRoles(t *testing.T) {
t.Parallel() t.Parallel()
cases := []struct { cases := []struct {
in string in string
want string want []string
isAdmin bool isAdmin bool
}{ }{
{"owner", "Owner", true}, {"owner", []string{"Owner"}, true},
{"free-owner", "Owner", true}, {"free-owner", []string{"Owner"}, true},
{"member", "Member", false}, {"member", []string{"Member"}, false},
{"removed", "Removed", false}, {"removed", []string{"Removed"}, false},
{"unknown_future_role", "unknown_future_role", false}, {"unknown_future_role", []string{"unknown_future_role"}, false},
{"", []string{}, false},
} }
for _, c := range cases { for _, c := range cases {
t.Run(c.in, func(t *testing.T) { t.Run(c.in, func(t *testing.T) {
t.Parallel() t.Parallel()
assert.Equal(t, c.want, cursorRole(c.in)) assert.Equal(t, c.want, cursorRoles(c.in))
assert.Equal(t, c.isAdmin, cursorIsAdmin(c.in)) assert.Equal(t, c.isAdmin, cursorIsAdmin(c.in))
}) })
} }

View File

@@ -107,19 +107,20 @@ func (d *DatadogDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro
active := !u.Attributes.Disabled active := !u.Attributes.Disabled
var ( var (
role string roles []string
isAdmin bool isAdmin bool
) )
for _, r := range u.Relationships.Roles.Data { for _, r := range u.Relationships.Roles.Data {
name := roleNames[r.ID] name := roleNames[r.ID]
if role == "" { if name == "" {
role = name continue
} }
roles = append(roles, name)
if strings.Contains(strings.ToLower(name), "admin") { if strings.Contains(strings.ToLower(name), "admin") {
isAdmin = true isAdmin = true
role = name
} }
} }
@@ -136,7 +137,7 @@ func (d *DatadogDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro
records = append(records, AccountRecord{ records = append(records, AccountRecord{
Email: u.Attributes.Email, Email: u.Attributes.Email,
FullName: u.Attributes.Name, FullName: u.Attributes.Name,
Role: role, Roles: roles,
JobTitle: u.Attributes.Title, JobTitle: u.Attributes.Title,
Active: &active, Active: &active,
IsAdmin: isAdmin, IsAdmin: isAdmin,

View File

@@ -42,7 +42,7 @@ func TestDatadogDriver(t *testing.T) {
require.NotNil(t, r.Active) require.NotNil(t, r.Active)
assert.True(t, *r.Active) assert.True(t, *r.Active)
assert.True(t, r.IsAdmin) assert.True(t, r.IsAdmin)
assert.Equal(t, "Datadog Admin Role", r.Role) assert.Equal(t, []string{"Datadog Admin Role"}, r.Roles)
assert.Equal(t, "Security Engineer", r.JobTitle) assert.Equal(t, "Security Engineer", r.JobTitle)
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, r.AccountType) assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, r.AccountType)
assert.Equal(t, coredata.MFAStatusEnabled, r.MFAStatus) assert.Equal(t, coredata.MFAStatusEnabled, r.MFAStatus)
@@ -56,7 +56,7 @@ func TestDatadogDriver(t *testing.T) {
require.NotNil(t, r2.Active) require.NotNil(t, r2.Active)
assert.False(t, *r2.Active) assert.False(t, *r2.Active)
assert.False(t, r2.IsAdmin) assert.False(t, r2.IsAdmin)
assert.Equal(t, "Datadog Standard Role", r2.Role) assert.Equal(t, []string{"Datadog Standard Role"}, r2.Roles)
assert.Equal(t, coredata.AccessReviewEntryAccountTypeServiceAccount, r2.AccountType) assert.Equal(t, coredata.AccessReviewEntryAccountTypeServiceAccount, r2.AccountType)
assert.Equal(t, coredata.MFAStatusDisabled, r2.MFAStatus) assert.Equal(t, coredata.MFAStatusDisabled, r2.MFAStatus)
} }

View File

@@ -90,10 +90,17 @@ func (d *DocuSignDriver) ListAccounts(ctx context.Context) ([]AccountRecord, err
} }
for _, u := range resp.Users { for _, u := range resp.Users {
role := strings.TrimSpace(u.PermissionProfileName)
roles := []string{}
if role != "" {
roles = []string{role}
}
record := AccountRecord{ record := AccountRecord{
Email: u.Email, Email: u.Email,
FullName: u.UserName, FullName: u.UserName,
Role: u.PermissionProfileName, Roles: roles,
JobTitle: u.JobTitle, JobTitle: u.JobTitle,
Active: new(strings.EqualFold(u.UserStatus, "active")), Active: new(strings.EqualFold(u.UserStatus, "active")),
IsAdmin: strings.EqualFold(u.IsAdmin, "True"), IsAdmin: strings.EqualFold(u.IsAdmin, "True"),

View File

@@ -38,5 +38,5 @@ func TestDocuSignDriver(t *testing.T) {
assert.NotEmpty(t, r.Email) assert.NotEmpty(t, r.Email)
assert.NotEmpty(t, r.FullName) assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.ExternalID) assert.NotEmpty(t, r.ExternalID)
assert.NotEmpty(t, r.Role) assert.NotEmpty(t, r.Roles)
} }

View File

@@ -37,8 +37,8 @@ import (
type AccountRecord struct { type AccountRecord struct {
Email string Email string
FullName string FullName string
Role string // system role/permission (e.g. "Admin", "Viewer") Roles []string // system roles/permissions (e.g. "Admin", "Viewer")
JobTitle string // HR job title / department (e.g. "Software Engineer") JobTitle string // HR job title / department (e.g. "Software Engineer")
Active *bool Active *bool
IsAdmin bool IsAdmin bool
MFAStatus coredata.MFAStatus MFAStatus coredata.MFAStatus

View File

@@ -21,6 +21,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"strings"
"time" "time"
"go.gearno.de/kit/log" "go.gearno.de/kit/log"
@@ -123,10 +124,17 @@ func (d *GitHubDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
} }
} }
role := strings.TrimSpace(membership.Role)
roles := []string{}
if role != "" {
roles = []string{role}
}
record := AccountRecord{ record := AccountRecord{
Email: profile.Email, Email: profile.Email,
FullName: fullName, FullName: fullName,
Role: membership.Role, Roles: roles,
Active: new(membership.State == "active"), Active: new(membership.State == "active"),
IsAdmin: membership.Role == "admin", IsAdmin: membership.Role == "admin",
MFAStatus: mfaStatus, MFAStatus: mfaStatus,

View File

@@ -43,5 +43,5 @@ func TestGitHubDriver(t *testing.T) {
r := records[0] r := records[0]
assert.NotEmpty(t, r.FullName) assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.ExternalID) assert.NotEmpty(t, r.ExternalID)
assert.NotEmpty(t, r.Role) assert.NotEmpty(t, r.Roles)
} }

View File

@@ -96,12 +96,12 @@ func (d *GitLabDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
active := m.State == "active" active := m.State == "active"
role := gitlabAccessLevelLabel(m.AccessLevel) roles := gitlabRoles(m.AccessLevel)
record := AccountRecord{ record := AccountRecord{
Email: m.Email, Email: m.Email,
FullName: fullName, FullName: fullName,
Role: role, Roles: roles,
Active: &active, Active: &active,
IsAdmin: m.AccessLevel >= 50, // 50 = Owner IsAdmin: m.AccessLevel >= 50, // 50 = Owner
MFAStatus: coredata.MFAStatusUnknown, MFAStatus: coredata.MFAStatusUnknown,
@@ -149,25 +149,25 @@ func (d *GitLabDriver) queryMembers(ctx context.Context, endpoint string) ([]git
return members, httpResp.Header.Get("Link"), nil return members, httpResp.Header.Get("Link"), nil
} }
// gitlabAccessLevelLabel maps GitLab numeric access levels to human // gitlabRoles maps GitLab numeric access levels to human
// labels. Source: https://docs.gitlab.com/api/members/#roles // labels. Source: https://docs.gitlab.com/api/members/#roles
func gitlabAccessLevelLabel(level int) string { func gitlabRoles(level int) []string {
switch level { switch level {
case 5: case 5:
return "Minimal Access" return []string{"Minimal Access"}
case 10: case 10:
return "Guest" return []string{"Guest"}
case 15: case 15:
return "Planner" return []string{"Planner"}
case 20: case 20:
return "Reporter" return []string{"Reporter"}
case 30: case 30:
return "Developer" return []string{"Developer"}
case 40: case 40:
return "Maintainer" return []string{"Maintainer"}
case 50: case 50:
return "Owner" return []string{"Owner"}
default: default:
return "" return []string{}
} }
} }

View File

@@ -43,7 +43,7 @@ func TestGitLabDriver(t *testing.T) {
r := records[0] r := records[0]
assert.NotEmpty(t, r.ExternalID) assert.NotEmpty(t, r.ExternalID)
assert.NotEmpty(t, r.FullName) assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.Role) assert.NotEmpty(t, r.Roles)
assert.Equal(t, coredata.MFAStatusUnknown, r.MFAStatus) assert.Equal(t, coredata.MFAStatusUnknown, r.MFAStatus)
require.NotNil(t, r.Active) require.NotNil(t, r.Active)
assert.True(t, *r.Active) assert.True(t, *r.Active)

View File

@@ -144,11 +144,11 @@ func (d *GoogleWorkspaceDriver) ListAccounts(ctx context.Context) ([]AccountReco
switch { switch {
case u.IsAdmin: case u.IsAdmin:
rec.Role = "Super Admin" rec.Roles = []string{"Super Admin"}
case u.IsDelegatedAdmin: case u.IsDelegatedAdmin:
rec.Role = "Delegated Admin" rec.Roles = []string{"Delegated Admin"}
default: default:
rec.Role = "User" rec.Roles = []string{"User"}
} }
records = append(records, rec) records = append(records, rec)

View File

@@ -38,5 +38,5 @@ func TestGoogleWorkspaceDriver(t *testing.T) {
assert.NotEmpty(t, r.Email) assert.NotEmpty(t, r.Email)
assert.NotEmpty(t, r.FullName) assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.ExternalID) assert.NotEmpty(t, r.ExternalID)
assert.NotEmpty(t, r.Role) assert.NotEmpty(t, r.Roles)
} }

View File

@@ -75,11 +75,18 @@ func (d *GrafanaDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro
continue continue
} }
role := strings.TrimSpace(u.Role)
roles := []string{}
if role != "" {
roles = []string{role}
}
record := AccountRecord{ record := AccountRecord{
Email: email, Email: email,
FullName: strings.TrimSpace(u.Name), FullName: strings.TrimSpace(u.Name),
Role: strings.TrimSpace(u.Role), Roles: roles,
IsAdmin: strings.EqualFold(strings.TrimSpace(u.Role), "Admin"), IsAdmin: strings.EqualFold(role, "Admin"),
MFAStatus: coredata.MFAStatusUnknown, MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser, AccountType: coredata.AccessReviewEntryAccountTypeUser,

View File

@@ -42,7 +42,7 @@ func TestGrafanaDriver(t *testing.T) {
assert.Equal(t, "admin@example.com", records[0].Email) assert.Equal(t, "admin@example.com", records[0].Email)
assert.Equal(t, "Admin User", records[0].FullName) assert.Equal(t, "Admin User", records[0].FullName)
assert.Equal(t, "Admin", records[0].Role) assert.Equal(t, []string{"Admin"}, records[0].Roles)
assert.True(t, records[0].IsAdmin) assert.True(t, records[0].IsAdmin)
assert.Equal(t, strconv.Itoa(1), records[0].ExternalID) assert.Equal(t, strconv.Itoa(1), records[0].ExternalID)
require.NotNil(t, records[0].Active) require.NotNil(t, records[0].Active)
@@ -51,7 +51,7 @@ func TestGrafanaDriver(t *testing.T) {
assert.Equal(t, "viewer@example.com", records[1].Email) assert.Equal(t, "viewer@example.com", records[1].Email)
assert.Equal(t, "Viewer User", records[1].FullName) assert.Equal(t, "Viewer User", records[1].FullName)
assert.Equal(t, "Viewer", records[1].Role) assert.Equal(t, []string{"Viewer"}, records[1].Roles)
assert.False(t, records[1].IsAdmin) assert.False(t, records[1].IsAdmin)
assert.Equal(t, strconv.Itoa(2), records[1].ExternalID) assert.Equal(t, strconv.Itoa(2), records[1].ExternalID)
require.NotNil(t, records[1].Active) require.NotNil(t, records[1].Active)

View File

@@ -20,6 +20,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"strings"
"time" "time"
"go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/coredata"
@@ -153,10 +154,17 @@ func (d *HerokuDriver) listTeamMembers(ctx context.Context) ([]AccountRecord, er
externalID = m.ID externalID = m.ID
} }
role := strings.TrimSpace(m.Role)
roles := []string{}
if role != "" {
roles = []string{role}
}
record := AccountRecord{ record := AccountRecord{
Email: email, Email: email,
FullName: fullName, FullName: fullName,
Role: m.Role, Roles: roles,
IsAdmin: isAdmin, IsAdmin: isAdmin,
MFAStatus: mfaStatus, MFAStatus: mfaStatus,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
@@ -262,10 +270,17 @@ func (d *HerokuDriver) listPersonalAccounts(ctx context.Context) ([]AccountRecor
// personal Heroku app. These endpoints expose no display name or MFA signal, // personal Heroku app. These endpoints expose no display name or MFA signal,
// so the email doubles as the full name and MFA is left unknown. // so the email doubles as the full name and MFA is left unknown.
func herokuPersonalRecord(externalID, email, role string, isAdmin bool) AccountRecord { func herokuPersonalRecord(externalID, email, role string, isAdmin bool) AccountRecord {
role = strings.TrimSpace(role)
roles := []string{}
if role != "" {
roles = []string{role}
}
return AccountRecord{ return AccountRecord{
Email: email, Email: email,
FullName: email, FullName: email,
Role: role, Roles: roles,
IsAdmin: isAdmin, IsAdmin: isAdmin,
MFAStatus: coredata.MFAStatusUnknown, MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,

View File

@@ -46,7 +46,7 @@ func TestHerokuDriver(t *testing.T) {
assert.NotEmpty(t, r.Email) assert.NotEmpty(t, r.Email)
assert.NotEmpty(t, r.ExternalID) assert.NotEmpty(t, r.ExternalID)
assert.NotEmpty(t, r.FullName) assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.Role) assert.NotEmpty(t, r.Roles)
assert.Equal(t, coredata.MFAStatusEnabled, r.MFAStatus) assert.Equal(t, coredata.MFAStatusEnabled, r.MFAStatus)
assert.True(t, r.IsAdmin) assert.True(t, r.IsAdmin)
require.NotNil(t, r.CreatedAt) require.NotNil(t, r.CreatedAt)
@@ -106,7 +106,7 @@ func TestHerokuDriverPersonalAccount(t *testing.T) {
assert.Contains(t, byEmail, "carol@example.com") assert.Contains(t, byEmail, "carol@example.com")
assert.True(t, byEmail["alice@example.com"].IsAdmin) assert.True(t, byEmail["alice@example.com"].IsAdmin)
assert.Equal(t, "owner", byEmail["alice@example.com"].Role) assert.Equal(t, []string{"owner"}, byEmail["alice@example.com"].Roles)
assert.False(t, byEmail["bob@example.com"].IsAdmin) assert.False(t, byEmail["bob@example.com"].IsAdmin)
} }

View File

@@ -92,25 +92,12 @@ func (d *HubSpotDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro
} }
for _, u := range resp.Results { for _, u := range resp.Results {
role := "User"
roleID := hubspotRoleID(u)
if roleMap != nil && roleID != "" {
if name, ok := roleMap[roleID]; ok {
role = name
} else if u.SuperAdmin {
role = "Super Admin"
}
} else if u.SuperAdmin {
role = "Super Admin"
}
fullName := strings.TrimSpace(u.FirstName + " " + u.LastName) fullName := strings.TrimSpace(u.FirstName + " " + u.LastName)
record := AccountRecord{ record := AccountRecord{
Email: u.Email, Email: u.Email,
FullName: fullName, FullName: fullName,
Role: role, Roles: hubspotRoles(u, roleMap),
Active: hubspotUserActive(u), Active: hubspotUserActive(u),
IsAdmin: u.SuperAdmin, IsAdmin: u.SuperAdmin,
ExternalID: u.ID, ExternalID: u.ID,
@@ -206,16 +193,65 @@ func (d *HubSpotDriver) fetchRoles(ctx context.Context) (map[string]string, erro
return roleMap, nil return roleMap, nil
} }
func hubspotRoleID(user hubspotUser) string { func hubspotRoles(user hubspotUser, roleMap map[string]string) []string {
if user.RoleID != "" { roleIDs := hubspotRoleIDs(user)
return user.RoleID
seen := make(map[string]struct{}, len(roleIDs)+1)
roles := make([]string, 0, len(roleIDs)+1)
if roleMap != nil {
for _, id := range roleIDs {
name, ok := roleMap[id]
if !ok {
continue
}
if _, dup := seen[name]; dup {
continue
}
seen[name] = struct{}{}
roles = append(roles, name)
}
} }
if len(user.RoleIDs) > 0 { if user.SuperAdmin {
return user.RoleIDs[0] if _, dup := seen["Super Admin"]; !dup {
roles = append(roles, "Super Admin")
}
} }
return "" if len(roles) > 0 {
return roles
}
return []string{"User"}
}
func hubspotRoleIDs(user hubspotUser) []string {
seen := make(map[string]struct{}, 1+len(user.RoleIDs))
ids := make([]string, 0, 1+len(user.RoleIDs))
add := func(id string) {
if id == "" {
return
}
if _, ok := seen[id]; ok {
return
}
seen[id] = struct{}{}
ids = append(ids, id)
}
add(user.RoleID)
for _, id := range user.RoleIDs {
add(id)
}
return ids
} }
func hubspotUserActive(user hubspotUser) *bool { func hubspotUserActive(user hubspotUser) *bool {

View File

@@ -73,7 +73,7 @@ func TestHubSpotDriverArchivedUsers(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Len(t, records, 2) require.Len(t, records, 2)
assert.Equal(t, "Sales Admin", records[0].Role) assert.Equal(t, []string{"Sales Admin"}, records[0].Roles)
require.NotNil(t, records[0].Active) require.NotNil(t, records[0].Active)
assert.True(t, *records[0].Active) assert.True(t, *records[0].Active)
@@ -83,6 +83,65 @@ func TestHubSpotDriverArchivedUsers(t *testing.T) {
assert.False(t, *records[1].Active) assert.False(t, *records[1].Active)
} }
func TestHubSpotRoles(t *testing.T) {
t.Parallel()
roleMap := map[string]string{
"role-1": "Sales Admin",
"role-2": "Marketing Admin",
}
tests := []struct {
name string
user hubspotUser
want []string
}{
{
name: "multiple role IDs",
user: hubspotUser{RoleIDs: []string{"role-1", "role-2"}},
want: []string{"Sales Admin", "Marketing Admin"},
},
{
name: "roleId and roleIds merged without duplicates",
user: hubspotUser{RoleID: "role-1", RoleIDs: []string{"role-1", "role-2"}},
want: []string{"Sales Admin", "Marketing Admin"},
},
{
name: "unknown role falls back to user",
user: hubspotUser{RoleIDs: []string{"missing"}},
want: []string{"User"},
},
{
name: "unknown role with super admin",
user: hubspotUser{RoleIDs: []string{"missing"}, SuperAdmin: true},
want: []string{"Super Admin"},
},
{
name: "known role merged with super admin",
user: hubspotUser{RoleIDs: []string{"role-1"}, SuperAdmin: true},
want: []string{"Sales Admin", "Super Admin"},
},
{
name: "multiple roles merged with super admin",
user: hubspotUser{RoleIDs: []string{"role-1", "role-2"}, SuperAdmin: true},
want: []string{"Sales Admin", "Marketing Admin", "Super Admin"},
},
{
name: "no roles defaults to user",
user: hubspotUser{},
want: []string{"User"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, hubspotRoles(tt.user, roleMap))
})
}
}
type roundTripFunc func(req *http.Request) (*http.Response, error) type roundTripFunc func(req *http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {

View File

@@ -66,7 +66,7 @@ func (d *IntercomDriver) ListAccounts(ctx context.Context) ([]AccountRecord, err
record := AccountRecord{ record := AccountRecord{
Email: a.Email, Email: a.Email,
FullName: a.Name, FullName: a.Name,
Role: intercomRole(a.HasInboxSeat), Roles: intercomRoles(a.HasInboxSeat),
JobTitle: a.JobTitle, JobTitle: a.JobTitle,
IsAdmin: false, // Intercom API does not expose admin role information IsAdmin: false, // Intercom API does not expose admin role information
ExternalID: a.ID, ExternalID: a.ID,
@@ -117,10 +117,10 @@ func (d *IntercomDriver) fetchAdmins(ctx context.Context) (*intercomAdminsRespon
// seat. The Intercom API does not expose a proper role field, so this is the // seat. The Intercom API does not expose a proper role field, so this is the
// best approximation available: users with inbox seats are active agents, // best approximation available: users with inbox seats are active agents,
// those without are limited/viewer users. // those without are limited/viewer users.
func intercomRole(hasInboxSeat bool) string { func intercomRoles(hasInboxSeat bool) []string {
if hasInboxSeat { if hasInboxSeat {
return "Agent" return []string{"Agent"}
} }
return "Viewer" return []string{"Viewer"}
} }

View File

@@ -96,7 +96,7 @@ func (d *LinearDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
record := AccountRecord{ record := AccountRecord{
Email: u.Email, Email: u.Email,
FullName: u.Name, FullName: u.Name,
Role: linearRole(u.Admin, u.Guest), Roles: linearRoles(u.Admin, u.Guest),
Active: new(u.Active), Active: new(u.Active),
IsAdmin: u.Admin, IsAdmin: u.Admin,
ExternalID: u.ID, ExternalID: u.ID,
@@ -200,13 +200,13 @@ query AccessReviewLinearUsers($after: String) {
return &resp, nil return &resp, nil
} }
func linearRole(admin, guest bool) string { func linearRoles(admin, guest bool) []string {
switch { switch {
case admin: case admin:
return "Admin" return []string{"Admin"}
case guest: case guest:
return "Guest" return []string{"Guest"}
default: default:
return "Member" return []string{"Member"}
} }
} }

View File

@@ -38,5 +38,5 @@ func TestLinearDriver(t *testing.T) {
assert.NotEmpty(t, r.Email) assert.NotEmpty(t, r.Email)
assert.NotEmpty(t, r.FullName) assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.ExternalID) assert.NotEmpty(t, r.ExternalID)
assert.NotEmpty(t, r.Role) assert.NotEmpty(t, r.Roles)
} }

View File

@@ -78,7 +78,7 @@ func (d *MetabaseDriver) ListAccounts(ctx context.Context) ([]AccountRecord, err
record := AccountRecord{ record := AccountRecord{
Email: u.Email, Email: u.Email,
FullName: metabaseFullName(u), FullName: metabaseFullName(u),
Role: metabaseRole(u.IsSuperuser), Roles: metabaseRoles(u.IsSuperuser),
Active: new(u.IsActive), Active: new(u.IsActive),
IsAdmin: u.IsSuperuser, IsAdmin: u.IsSuperuser,
ExternalID: strconv.Itoa(u.ID), ExternalID: strconv.Itoa(u.ID),
@@ -176,12 +176,12 @@ func metabaseFullName(u metabaseUser) string {
return strings.TrimSpace(strings.Join([]string{u.FirstName, u.LastName}, " ")) return strings.TrimSpace(strings.Join([]string{u.FirstName, u.LastName}, " "))
} }
func metabaseRole(isSuperuser bool) string { func metabaseRoles(isSuperuser bool) []string {
if isSuperuser { if isSuperuser {
return "Admin" return []string{"Admin"}
} }
return "User" return []string{"User"}
} }
// metabaseNameResolver resolves the Metabase site name by querying // metabaseNameResolver resolves the Metabase site name by querying

View File

@@ -41,7 +41,7 @@ func TestMetabaseDriver(t *testing.T) {
assert.Equal(t, "alice@example.com", records[0].Email) assert.Equal(t, "alice@example.com", records[0].Email)
assert.Equal(t, "Alice A.", records[0].FullName) assert.Equal(t, "Alice A.", records[0].FullName)
assert.Equal(t, "Admin", records[0].Role) assert.Equal(t, []string{"Admin"}, records[0].Roles)
assert.True(t, records[0].IsAdmin) assert.True(t, records[0].IsAdmin)
require.NotNil(t, records[0].Active) require.NotNil(t, records[0].Active)
assert.True(t, *records[0].Active) assert.True(t, *records[0].Active)
@@ -51,7 +51,7 @@ func TestMetabaseDriver(t *testing.T) {
assert.Equal(t, "bob@example.com", records[1].Email) assert.Equal(t, "bob@example.com", records[1].Email)
assert.Equal(t, "Bob Builder", records[1].FullName) assert.Equal(t, "Bob Builder", records[1].FullName)
assert.Equal(t, "User", records[1].Role) assert.Equal(t, []string{"User"}, records[1].Roles)
assert.False(t, records[1].IsAdmin) assert.False(t, records[1].IsAdmin)
require.NotNil(t, records[1].Active) require.NotNil(t, records[1].Active)
assert.False(t, *records[1].Active) assert.False(t, *records[1].Active)

View File

@@ -164,16 +164,16 @@ func (d *Microsoft365Driver) ListAccounts(ctx context.Context) ([]AccountRecord,
} }
} }
role := pickHighestRole(userRoles) roles := userRoles
if role == "" { if len(roles) == 0 {
role = "User" roles = []string{"User"}
} }
active := u.AccountEnabled active := u.AccountEnabled
rec := AccountRecord{ rec := AccountRecord{
Email: email, Email: email,
FullName: u.DisplayName, FullName: u.DisplayName,
Role: role, Roles: roles,
JobTitle: u.JobTitle, JobTitle: u.JobTitle,
Active: &active, Active: &active,
IsAdmin: isAdmin, IsAdmin: isAdmin,
@@ -195,39 +195,6 @@ func (d *Microsoft365Driver) ListAccounts(ctx context.Context) ([]AccountRecord,
return records, nil return records, nil
} }
// pickHighestRole returns the most privileged admin role from the list,
// falling back to the first non-admin role when no admin role is present.
// Privilege order is hard-coded to Microsoft's well-known directory roles.
func pickHighestRole(roles []string) string {
priority := []string{
"Global Administrator",
"Company Administrator",
"Privileged Role Administrator",
"Privileged Authentication Administrator",
"Security Administrator",
"Application Administrator",
"Cloud Application Administrator",
"User Administrator",
"Conditional Access Administrator",
"Compliance Administrator",
"Authentication Administrator",
}
for _, p := range priority {
for _, r := range roles {
if r == p {
return r
}
}
}
if len(roles) > 0 {
return roles[0]
}
return ""
}
func (d *Microsoft365Driver) listUsers(ctx context.Context) ([]microsoft365User, error) { func (d *Microsoft365Driver) listUsers(ctx context.Context) ([]microsoft365User, error) {
pageURL, err := buildMicrosoft365UsersURL() pageURL, err := buildMicrosoft365UsersURL()
if err != nil { if err != nil {

View File

@@ -90,7 +90,7 @@ func (d *NeonDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error)
// The members endpoint exposes no display name; // The members endpoint exposes no display name;
// fall back to the email. // fall back to the email.
FullName: m.User.Email, FullName: m.User.Email,
Role: neonRole(m.Member.Role), Roles: neonRoles(m.Member.Role),
// deactivated_at is absent for active accounts. // deactivated_at is absent for active accounts.
Active: new(m.User.DeactivatedAt == ""), Active: new(m.User.DeactivatedAt == ""),
IsAdmin: neonIsAdmin(m.Member.Role), IsAdmin: neonIsAdmin(m.Member.Role),
@@ -160,22 +160,26 @@ func (d *NeonDriver) queryMembers(ctx context.Context, cursor string) (*neonMemb
return &resp, nil return &resp, nil
} }
// neonRole maps Neon's lowercase member roles to their display form, // neonRoles maps Neon's lowercase member roles to their display form,
// passing unknown values through unchanged. // passing unknown values through unchanged.
func neonRole(role string) string { func neonRoles(role string) []string {
if role == "" {
return []string{}
}
switch strings.ToLower(role) { switch strings.ToLower(role) {
case "admin": case "admin":
return "Admin" return []string{"Admin"}
case "member": case "member":
return "Member" return []string{"Member"}
case "editor": case "editor":
return "Editor" return []string{"Editor"}
case "viewer": case "viewer":
return "Viewer" return []string{"Viewer"}
case "collaborator": case "collaborator":
return "Collaborator" return []string{"Collaborator"}
default: default:
return role return []string{role}
} }
} }

View File

@@ -51,7 +51,7 @@ func TestNeonDriverListAccounts(t *testing.T) {
// ExternalID is the stable account UUID (member.user_id). // ExternalID is the stable account UUID (member.user_id).
assert.Equal(t, "jane.doe@example.com", records[0].Email) assert.Equal(t, "jane.doe@example.com", records[0].Email)
assert.Equal(t, "jane.doe@example.com", records[0].FullName) assert.Equal(t, "jane.doe@example.com", records[0].FullName)
assert.Equal(t, "Admin", records[0].Role) assert.Equal(t, []string{"Admin"}, records[0].Roles)
assert.True(t, records[0].IsAdmin) assert.True(t, records[0].IsAdmin)
assert.Equal(t, coredata.MFAStatusEnabled, records[0].MFAStatus) assert.Equal(t, coredata.MFAStatusEnabled, records[0].MFAStatus)
assert.Equal(t, "bbbbbbbb-1111-2222-3333-000000000001", records[0].ExternalID) assert.Equal(t, "bbbbbbbb-1111-2222-3333-000000000001", records[0].ExternalID)
@@ -62,7 +62,7 @@ func TestNeonDriverListAccounts(t *testing.T) {
// Deactivated member with MFA disabled. // Deactivated member with MFA disabled.
assert.Equal(t, "john.smith@example.com", records[1].Email) assert.Equal(t, "john.smith@example.com", records[1].Email)
assert.Equal(t, "Member", records[1].Role) assert.Equal(t, []string{"Member"}, records[1].Roles)
assert.False(t, records[1].IsAdmin) assert.False(t, records[1].IsAdmin)
assert.Equal(t, coredata.MFAStatusDisabled, records[1].MFAStatus) assert.Equal(t, coredata.MFAStatusDisabled, records[1].MFAStatus)
assert.Equal(t, "bbbbbbbb-1111-2222-3333-000000000002", records[1].ExternalID) assert.Equal(t, "bbbbbbbb-1111-2222-3333-000000000002", records[1].ExternalID)
@@ -72,7 +72,7 @@ func TestNeonDriverListAccounts(t *testing.T) {
// Second page: editor with has_mfa omitted (Unknown) and an empty // Second page: editor with has_mfa omitted (Unknown) and an empty
// user_id falling back to the membership ID. // user_id falling back to the membership ID.
assert.Equal(t, "erin.lee@example.com", records[2].Email) assert.Equal(t, "erin.lee@example.com", records[2].Email)
assert.Equal(t, "Editor", records[2].Role) assert.Equal(t, []string{"Editor"}, records[2].Roles)
assert.False(t, records[2].IsAdmin) assert.False(t, records[2].IsAdmin)
assert.Equal(t, coredata.MFAStatusUnknown, records[2].MFAStatus) assert.Equal(t, coredata.MFAStatusUnknown, records[2].MFAStatus)
assert.Equal(t, "aaaaaaaa-1111-2222-3333-000000000003", records[2].ExternalID) assert.Equal(t, "aaaaaaaa-1111-2222-3333-000000000003", records[2].ExternalID)
@@ -101,27 +101,28 @@ func TestNeonDriverListAccountsError(t *testing.T) {
assert.Contains(t, err.Error(), "unexpected status 401") assert.Contains(t, err.Error(), "unexpected status 401")
} }
func TestNeonRole(t *testing.T) { func TestNeonRoles(t *testing.T) {
t.Parallel() t.Parallel()
cases := []struct { cases := []struct {
in string in string
want string want []string
isAdmin bool isAdmin bool
}{ }{
{in: "admin", want: "Admin", isAdmin: true}, {in: "admin", want: []string{"Admin"}, isAdmin: true},
{in: "member", want: "Member", isAdmin: false}, {in: "member", want: []string{"Member"}, isAdmin: false},
{in: "editor", want: "Editor", isAdmin: false}, {in: "editor", want: []string{"Editor"}, isAdmin: false},
{in: "viewer", want: "Viewer", isAdmin: false}, {in: "viewer", want: []string{"Viewer"}, isAdmin: false},
{in: "collaborator", want: "Collaborator", isAdmin: false}, {in: "collaborator", want: []string{"Collaborator"}, isAdmin: false},
{in: "future_role", want: "future_role", isAdmin: false}, {in: "future_role", want: []string{"future_role"}, isAdmin: false},
{in: "", want: []string{}, isAdmin: false},
} }
for _, c := range cases { for _, c := range cases {
t.Run(c.in, func(t *testing.T) { t.Run(c.in, func(t *testing.T) {
t.Parallel() t.Parallel()
assert.Equal(t, c.want, neonRole(c.in)) assert.Equal(t, c.want, neonRoles(c.in))
assert.Equal(t, c.isAdmin, neonIsAdmin(c.in)) assert.Equal(t, c.isAdmin, neonIsAdmin(c.in))
}) })
} }

View File

@@ -20,6 +20,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"strings"
"go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/rfc5988" "go.probo.inc/probo/pkg/rfc5988"
@@ -78,10 +79,17 @@ func (d *NetlifyDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro
} }
for _, m := range members { for _, m := range members {
role := strings.TrimSpace(m.Role)
roles := []string{}
if role != "" {
roles = []string{role}
}
record := AccountRecord{ record := AccountRecord{
Email: m.Email, Email: m.Email,
FullName: m.FullName, FullName: m.FullName,
Role: m.Role, Roles: roles,
ExternalID: m.ID, ExternalID: m.ID,
MFAStatus: coredata.MFAStatusUnknown, MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,

View File

@@ -43,5 +43,5 @@ func TestNetlifyDriver(t *testing.T) {
assert.NotEmpty(t, r.ExternalID) assert.NotEmpty(t, r.ExternalID)
assert.NotEmpty(t, r.Email) assert.NotEmpty(t, r.Email)
assert.NotEmpty(t, r.FullName) assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.Role) assert.NotEmpty(t, r.Roles)
} }

View File

@@ -80,7 +80,7 @@ func (d *NotionDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
record := AccountRecord{ record := AccountRecord{
Email: email, Email: email,
FullName: u.Name, FullName: u.Name,
Role: "Member", Roles: []string{"Member"},
IsAdmin: false, IsAdmin: false,
ExternalID: u.ID, ExternalID: u.ID,
MFAStatus: coredata.MFAStatusUnknown, MFAStatus: coredata.MFAStatusUnknown,

View File

@@ -67,7 +67,7 @@ func (d *OpenAIDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
record := AccountRecord{ record := AccountRecord{
Email: u.Email, Email: u.Email,
FullName: u.Name, FullName: u.Name,
Role: openaiRole(u.Role), Roles: openaiRoles(u.Role),
Active: new(!u.Disabled), Active: new(!u.Disabled),
IsAdmin: u.Role == "owner", IsAdmin: u.Role == "owner",
ExternalID: u.ID, ExternalID: u.ID,
@@ -134,13 +134,17 @@ func (d *OpenAIDriver) fetchUsers(ctx context.Context, after string) (*openaiUse
return &resp, nil return &resp, nil
} }
func openaiRole(role string) string { func openaiRoles(role string) []string {
if role == "" {
return []string{}
}
switch role { switch role {
case "owner": case "owner":
return "Owner" return []string{"Owner"}
case "reader": case "reader":
return "Reader" return []string{"Reader"}
default: default:
return "Member" return []string{"Member"}
} }
} }

View File

@@ -38,5 +38,5 @@ func TestOpenAIDriver(t *testing.T) {
assert.NotEmpty(t, r.Email) assert.NotEmpty(t, r.Email)
assert.NotEmpty(t, r.FullName) assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.ExternalID) assert.NotEmpty(t, r.ExternalID)
assert.NotEmpty(t, r.Role) assert.NotEmpty(t, r.Roles)
} }

View File

@@ -21,6 +21,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"strings"
"time" "time"
"go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/coredata"
@@ -77,10 +78,17 @@ func (d *PagerDutyDriver) ListAccounts(ctx context.Context) ([]AccountRecord, er
for _, u := range page.Users { for _, u := range page.Users {
isAdmin := u.Role == "admin" || u.Role == "owner" isAdmin := u.Role == "admin" || u.Role == "owner"
role := strings.TrimSpace(u.Role)
roles := []string{}
if role != "" {
roles = []string{role}
}
record := AccountRecord{ record := AccountRecord{
Email: u.Email, Email: u.Email,
FullName: u.Name, FullName: u.Name,
Role: u.Role, Roles: roles,
IsAdmin: isAdmin, IsAdmin: isAdmin,
MFAStatus: coredata.MFAStatusUnknown, MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,

View File

@@ -38,6 +38,6 @@ func TestPagerDutyDriver(t *testing.T) {
assert.NotEmpty(t, r.Email) assert.NotEmpty(t, r.Email)
assert.NotEmpty(t, r.ExternalID) assert.NotEmpty(t, r.ExternalID)
assert.NotEmpty(t, r.FullName) assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.Role) assert.NotEmpty(t, r.Roles)
assert.True(t, r.IsAdmin) assert.True(t, r.IsAdmin)
} }

View File

@@ -292,7 +292,7 @@ func posthogAccountRecord(member posthogMember) AccountRecord {
record := AccountRecord{ record := AccountRecord{
Email: member.User.Email, Email: member.User.Email,
FullName: posthogFullName(member.User), FullName: posthogFullName(member.User),
Role: posthogRole(member.Level, member.User.RoleAtOrganization), Roles: posthogRoles(member.Level, member.User.RoleAtOrganization),
IsAdmin: posthogIsAdmin(member.Level), IsAdmin: posthogIsAdmin(member.Level),
ExternalID: member.User.UUID, ExternalID: member.User.UUID,
MFAStatus: posthogMFAStatus(member.Is2FAEnabled), MFAStatus: posthogMFAStatus(member.Is2FAEnabled),
@@ -319,18 +319,18 @@ func posthogFullName(user posthogMemberUser) string {
return strings.TrimSpace(strings.Join([]string{user.FirstName, user.LastName}, " ")) return strings.TrimSpace(strings.Join([]string{user.FirstName, user.LastName}, " "))
} }
func posthogRole(level int, fallback string) string { func posthogRoles(level int, fallback string) []string {
switch { switch {
case level >= posthogMembershipLevelOwner: case level >= posthogMembershipLevelOwner:
return "Owner" return []string{"Owner"}
case level >= posthogMembershipLevelAdmin: case level >= posthogMembershipLevelAdmin:
return "Admin" return []string{"Admin"}
case level >= posthogMembershipLevelMember: case level >= posthogMembershipLevelMember:
return "Member" return []string{"Member"}
case fallback != "": case fallback != "":
return fallback return []string{fallback}
default: default:
return "Member" return []string{"Member"}
} }
} }

View File

@@ -41,7 +41,7 @@ func TestPostHogDriverListAccounts(t *testing.T) {
owner := records[0] owner := records[0]
assert.Equal(t, "owner@example.com", owner.Email) assert.Equal(t, "owner@example.com", owner.Email)
assert.Equal(t, "Olivia Owner", owner.FullName) assert.Equal(t, "Olivia Owner", owner.FullName)
assert.Equal(t, "Owner", owner.Role) assert.Equal(t, []string{"Owner"}, owner.Roles)
assert.True(t, owner.IsAdmin) assert.True(t, owner.IsAdmin)
assert.Equal(t, coredata.MFAStatusEnabled, owner.MFAStatus) assert.Equal(t, coredata.MFAStatusEnabled, owner.MFAStatus)
assert.Equal(t, "user-1", owner.ExternalID) assert.Equal(t, "user-1", owner.ExternalID)
@@ -50,7 +50,7 @@ func TestPostHogDriverListAccounts(t *testing.T) {
member := records[1] member := records[1]
assert.Equal(t, "member@example.com", member.Email) assert.Equal(t, "member@example.com", member.Email)
assert.Equal(t, "Member", member.Role) assert.Equal(t, []string{"Member"}, member.Roles)
assert.False(t, member.IsAdmin) assert.False(t, member.IsAdmin)
assert.Equal(t, coredata.MFAStatusDisabled, member.MFAStatus) assert.Equal(t, coredata.MFAStatusDisabled, member.MFAStatus)
require.NotNil(t, member.CreatedAt) require.NotNil(t, member.CreatedAt)
@@ -58,7 +58,7 @@ func TestPostHogDriverListAccounts(t *testing.T) {
admin := records[2] admin := records[2]
assert.Equal(t, "admin@example.com", admin.Email) assert.Equal(t, "admin@example.com", admin.Email)
assert.Equal(t, "Admin", admin.Role) assert.Equal(t, []string{"Admin"}, admin.Roles)
assert.True(t, admin.IsAdmin) assert.True(t, admin.IsAdmin)
assert.Equal(t, coredata.MFAStatusUnknown, admin.MFAStatus) assert.Equal(t, coredata.MFAStatusUnknown, admin.MFAStatus)
assert.Equal(t, "membership-3", admin.ExternalID) assert.Equal(t, "membership-3", admin.ExternalID)
@@ -177,12 +177,12 @@ func TestPostHogNameResolver(t *testing.T) {
} }
} }
func TestPostHogRoleFallback(t *testing.T) { func TestPostHogRolesFallback(t *testing.T) {
t.Parallel() t.Parallel()
assert.Equal(t, "Owner", posthogRole(15, "")) assert.Equal(t, []string{"Owner"}, posthogRoles(15, ""))
assert.Equal(t, "Admin", posthogRole(8, "")) assert.Equal(t, []string{"Admin"}, posthogRoles(8, ""))
assert.Equal(t, "Member", posthogRole(1, "")) assert.Equal(t, []string{"Member"}, posthogRoles(1, ""))
assert.Equal(t, "engineering", posthogRole(0, "engineering")) assert.Equal(t, []string{"engineering"}, posthogRoles(0, "engineering"))
assert.Equal(t, "Member", posthogRole(0, "")) assert.Equal(t, []string{"Member"}, posthogRoles(0, ""))
} }

View File

@@ -17,6 +17,7 @@ package drivers
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"go.gearno.de/kit/pg" "go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/coredata"
@@ -61,7 +62,13 @@ func (d *ProboMembershipsDriver) ListAccounts(ctx context.Context) ([]AccountRec
} }
for _, account := range accounts { for _, account := range accounts {
role := account.Role role := strings.TrimSpace(account.Role)
roles := []string{}
if role != "" {
roles = []string{role}
}
isAdmin := role == string(coredata.MembershipRoleOwner) || role == string(coredata.MembershipRoleAdmin) isAdmin := role == string(coredata.MembershipRoleOwner) || role == string(coredata.MembershipRoleAdmin)
createdAt := account.CreatedAt createdAt := account.CreatedAt
@@ -70,7 +77,7 @@ func (d *ProboMembershipsDriver) ListAccounts(ctx context.Context) ([]AccountRec
AccountRecord{ AccountRecord{
Email: account.Email, Email: account.Email,
FullName: account.FullName, FullName: account.FullName,
Role: role, Roles: roles,
Active: new(account.State == string(coredata.ProfileStateActive)), Active: new(account.State == string(coredata.ProfileStateActive)),
IsAdmin: isAdmin, IsAdmin: isAdmin,
ExternalID: account.ID.String(), ExternalID: account.ID.String(),

View File

@@ -101,7 +101,7 @@ func (d *QoveryDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
record := AccountRecord{ record := AccountRecord{
Email: member.Email, Email: member.Email,
FullName: qoveryFullName(member), FullName: qoveryFullName(member),
Role: qoveryRole(member.Role), Roles: qoveryRoles(member.Role),
IsAdmin: qoveryIsAdmin(member.Role), IsAdmin: qoveryIsAdmin(member.Role),
MFAStatus: coredata.MFAStatusUnknown, MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
@@ -139,18 +139,22 @@ func qoveryFullName(member qoveryMember) string {
return member.Email return member.Email
} }
func qoveryRole(role string) string { func qoveryRoles(role string) []string {
if role == "" {
return []string{}
}
switch strings.ToUpper(role) { switch strings.ToUpper(role) {
case "OWNER": case "OWNER":
return "Owner" return []string{"Owner"}
case "ADMIN": case "ADMIN":
return "Admin" return []string{"Admin"}
case "DEVELOPER": case "DEVELOPER":
return "Developer" return []string{"Developer"}
case "VIEWER": case "VIEWER":
return "Viewer" return []string{"Viewer"}
default: default:
return role return []string{role}
} }
} }

View File

@@ -54,7 +54,7 @@ func TestQoveryDriverListAccounts(t *testing.T) {
// Qovery member IDs are the IdP subject (e.g. "google-oauth2|<sub>"). // Qovery member IDs are the IdP subject (e.g. "google-oauth2|<sub>").
assert.Equal(t, "jane.doe@example.com", records[0].Email) assert.Equal(t, "jane.doe@example.com", records[0].Email)
assert.Equal(t, "Jane Doe", records[0].FullName) assert.Equal(t, "Jane Doe", records[0].FullName)
assert.Equal(t, "Owner", records[0].Role) assert.Equal(t, []string{"Owner"}, records[0].Roles)
assert.True(t, records[0].IsAdmin) assert.True(t, records[0].IsAdmin)
assert.Equal(t, "google-oauth2|100000000000000000001", records[0].ExternalID) assert.Equal(t, "google-oauth2|100000000000000000001", records[0].ExternalID)
require.NotNil(t, records[0].LastLogin) require.NotNil(t, records[0].LastLogin)
@@ -65,7 +65,7 @@ func TestQoveryDriverListAccounts(t *testing.T) {
// leaves LastLogin nil. // leaves LastLogin nil.
assert.Equal(t, "john.smith@example.com", records[1].Email) assert.Equal(t, "john.smith@example.com", records[1].Email)
assert.Equal(t, "john", records[1].FullName) assert.Equal(t, "john", records[1].FullName)
assert.Equal(t, "Developer", records[1].Role) assert.Equal(t, []string{"Developer"}, records[1].Roles)
assert.False(t, records[1].IsAdmin) assert.False(t, records[1].IsAdmin)
assert.Equal(t, "google-oauth2|100000000000000000002", records[1].ExternalID) assert.Equal(t, "google-oauth2|100000000000000000002", records[1].ExternalID)
assert.Nil(t, records[1].LastLogin) assert.Nil(t, records[1].LastLogin)
@@ -93,26 +93,27 @@ func TestQoveryDriverListAccountsError(t *testing.T) {
assert.Contains(t, err.Error(), "unexpected status 401") assert.Contains(t, err.Error(), "unexpected status 401")
} }
func TestQoveryRole(t *testing.T) { func TestQoveryRoles(t *testing.T) {
t.Parallel() t.Parallel()
cases := []struct { cases := []struct {
in string in string
want string want []string
isAdmin bool isAdmin bool
}{ }{
{in: "OWNER", want: "Owner", isAdmin: true}, {in: "OWNER", want: []string{"Owner"}, isAdmin: true},
{in: "ADMIN", want: "Admin", isAdmin: true}, {in: "ADMIN", want: []string{"Admin"}, isAdmin: true},
{in: "DEVELOPER", want: "Developer", isAdmin: false}, {in: "DEVELOPER", want: []string{"Developer"}, isAdmin: false},
{in: "VIEWER", want: "Viewer", isAdmin: false}, {in: "VIEWER", want: []string{"Viewer"}, isAdmin: false},
{in: "future_role", want: "future_role", isAdmin: false}, {in: "future_role", want: []string{"future_role"}, isAdmin: false},
{in: "", want: []string{}, isAdmin: false},
} }
for _, c := range cases { for _, c := range cases {
t.Run(c.in, func(t *testing.T) { t.Run(c.in, func(t *testing.T) {
t.Parallel() t.Parallel()
assert.Equal(t, c.want, qoveryRole(c.in)) assert.Equal(t, c.want, qoveryRoles(c.in))
assert.Equal(t, c.isAdmin, qoveryIsAdmin(c.in)) assert.Equal(t, c.isAdmin, qoveryIsAdmin(c.in))
}) })
} }

View File

@@ -99,7 +99,7 @@ func (d *RenderDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
records = append(records, AccountRecord{ records = append(records, AccountRecord{
Email: member.Email, Email: member.Email,
FullName: renderFullName(member), FullName: renderFullName(member),
Role: renderRole(member.Role), Roles: renderRoles(member.Role),
Active: renderActive(member.Status), Active: renderActive(member.Status),
IsAdmin: renderIsAdmin(member.Role), IsAdmin: renderIsAdmin(member.Role),
MFAStatus: renderMFAStatus(member.MFAEnabled), MFAStatus: renderMFAStatus(member.MFAEnabled),
@@ -120,24 +120,28 @@ func renderFullName(member renderMember) string {
return member.Email return member.Email
} }
// renderRole maps Render's uppercase role enum to a human-readable label. // renderRoles maps Render's uppercase role enum to a human-readable label.
// Render documents ADMIN, DEVELOPER, WORKSPACE_CONTRIBUTOR, // Render documents ADMIN, DEVELOPER, WORKSPACE_CONTRIBUTOR,
// WORKSPACE_BILLING, and WORKSPACE_VIEWER; unknown future roles fall through // WORKSPACE_BILLING, and WORKSPACE_VIEWER; unknown future roles fall through
// to the raw value. // to the raw value.
func renderRole(role string) string { func renderRoles(role string) []string {
if role == "" {
return []string{}
}
switch strings.ToUpper(role) { switch strings.ToUpper(role) {
case "ADMIN": case "ADMIN":
return "Admin" return []string{"Admin"}
case "DEVELOPER": case "DEVELOPER":
return "Developer" return []string{"Developer"}
case "WORKSPACE_CONTRIBUTOR": case "WORKSPACE_CONTRIBUTOR":
return "Contributor" return []string{"Contributor"}
case "WORKSPACE_BILLING": case "WORKSPACE_BILLING":
return "Billing" return []string{"Billing"}
case "WORKSPACE_VIEWER": case "WORKSPACE_VIEWER":
return "Viewer" return []string{"Viewer"}
default: default:
return role return []string{role}
} }
} }

View File

@@ -51,7 +51,7 @@ func TestRenderDriverListAccounts(t *testing.T) {
// never the email. // never the email.
assert.Equal(t, "jane.doe@example.com", records[0].Email) assert.Equal(t, "jane.doe@example.com", records[0].Email)
assert.Equal(t, "Jane Doe", records[0].FullName) assert.Equal(t, "Jane Doe", records[0].FullName)
assert.Equal(t, "Admin", records[0].Role) assert.Equal(t, []string{"Admin"}, records[0].Roles)
assert.True(t, records[0].IsAdmin) assert.True(t, records[0].IsAdmin)
assert.Equal(t, coredata.MFAStatusEnabled, records[0].MFAStatus) assert.Equal(t, coredata.MFAStatusEnabled, records[0].MFAStatus)
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, records[0].AccountType) assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, records[0].AccountType)
@@ -63,7 +63,7 @@ func TestRenderDriverListAccounts(t *testing.T) {
// Developer: active, MFA disabled, not an admin. // Developer: active, MFA disabled, not an admin.
assert.Equal(t, "john.smith@example.com", records[1].Email) assert.Equal(t, "john.smith@example.com", records[1].Email)
assert.Equal(t, "John Smith", records[1].FullName) assert.Equal(t, "John Smith", records[1].FullName)
assert.Equal(t, "Developer", records[1].Role) assert.Equal(t, []string{"Developer"}, records[1].Roles)
assert.False(t, records[1].IsAdmin) assert.False(t, records[1].IsAdmin)
assert.Equal(t, coredata.MFAStatusDisabled, records[1].MFAStatus) assert.Equal(t, coredata.MFAStatusDisabled, records[1].MFAStatus)
assert.Equal(t, "usr-000000000000000000b2", records[1].ExternalID) assert.Equal(t, "usr-000000000000000000b2", records[1].ExternalID)
@@ -74,7 +74,7 @@ func TestRenderDriverListAccounts(t *testing.T) {
// email so the row is never nameless. // email so the row is never nameless.
assert.Equal(t, "sam.viewer@example.com", records[2].Email) assert.Equal(t, "sam.viewer@example.com", records[2].Email)
assert.Equal(t, "sam.viewer@example.com", records[2].FullName) assert.Equal(t, "sam.viewer@example.com", records[2].FullName)
assert.Equal(t, "Viewer", records[2].Role) assert.Equal(t, []string{"Viewer"}, records[2].Roles)
assert.False(t, records[2].IsAdmin) assert.False(t, records[2].IsAdmin)
assert.Equal(t, coredata.MFAStatusDisabled, records[2].MFAStatus) assert.Equal(t, coredata.MFAStatusDisabled, records[2].MFAStatus)
assert.Equal(t, "usr-000000000000000000c3", records[2].ExternalID) assert.Equal(t, "usr-000000000000000000c3", records[2].ExternalID)
@@ -105,27 +105,28 @@ func TestRenderDriverListAccountsError(t *testing.T) {
assert.NotContains(t, err.Error(), "unauthorized") assert.NotContains(t, err.Error(), "unauthorized")
} }
func TestRenderRole(t *testing.T) { func TestRenderRoles(t *testing.T) {
t.Parallel() t.Parallel()
cases := []struct { cases := []struct {
in string in string
want string want []string
isAdmin bool isAdmin bool
}{ }{
{in: "ADMIN", want: "Admin", isAdmin: true}, {in: "ADMIN", want: []string{"Admin"}, isAdmin: true},
{in: "DEVELOPER", want: "Developer", isAdmin: false}, {in: "DEVELOPER", want: []string{"Developer"}, isAdmin: false},
{in: "WORKSPACE_CONTRIBUTOR", want: "Contributor", isAdmin: false}, {in: "WORKSPACE_CONTRIBUTOR", want: []string{"Contributor"}, isAdmin: false},
{in: "WORKSPACE_BILLING", want: "Billing", isAdmin: false}, {in: "WORKSPACE_BILLING", want: []string{"Billing"}, isAdmin: false},
{in: "WORKSPACE_VIEWER", want: "Viewer", isAdmin: false}, {in: "WORKSPACE_VIEWER", want: []string{"Viewer"}, isAdmin: false},
{in: "future_role", want: "future_role", isAdmin: false}, {in: "future_role", want: []string{"future_role"}, isAdmin: false},
{in: "", want: []string{}, isAdmin: false},
} }
for _, c := range cases { for _, c := range cases {
t.Run(c.in, func(t *testing.T) { t.Run(c.in, func(t *testing.T) {
t.Parallel() t.Parallel()
assert.Equal(t, c.want, renderRole(c.in)) assert.Equal(t, c.want, renderRoles(c.in))
assert.Equal(t, c.isAdmin, renderIsAdmin(c.in)) assert.Equal(t, c.isAdmin, renderIsAdmin(c.in))
}) })
} }

View File

@@ -103,7 +103,7 @@ func (d *SendGridDriver) ListAccounts(ctx context.Context) ([]AccountRecord, err
records = append(records, AccountRecord{ records = append(records, AccountRecord{
Email: teammate.Email, Email: teammate.Email,
FullName: sendGridFullName(teammate.FirstName, teammate.LastName), FullName: sendGridFullName(teammate.FirstName, teammate.LastName),
Role: sendGridRole(teammate.UserType, teammate.IsAdmin), Roles: sendGridRoles(teammate.UserType, teammate.IsAdmin),
IsAdmin: teammate.IsAdmin, IsAdmin: teammate.IsAdmin,
// SendGrid exposes no UUID for teammates; the username is the // SendGrid exposes no UUID for teammates; the username is the
// only stable handle. For unified accounts it equals the email. // only stable handle. For unified accounts it equals the email.
@@ -209,22 +209,22 @@ func sendGridFullName(firstName, lastName string) string {
return strings.TrimSpace(strings.Join([]string{firstName, lastName}, " ")) return strings.TrimSpace(strings.Join([]string{firstName, lastName}, " "))
} }
func sendGridRole(userType string, isAdmin bool) string { func sendGridRoles(userType string, isAdmin bool) []string {
switch userType { switch userType {
case "owner": case "owner":
return "Owner" return []string{"Owner"}
case "admin": case "admin":
return "Admin" return []string{"Admin"}
case "teammate": case "teammate":
return "Teammate" return []string{"Teammate"}
case "": case "":
if isAdmin { if isAdmin {
return "Admin" return []string{"Admin"}
} }
return "Teammate" return []string{"Teammate"}
default: default:
return userType return []string{userType}
} }
} }

View File

@@ -43,7 +43,7 @@ func TestSendGridDriver(t *testing.T) {
owner := records[0] owner := records[0]
assert.Equal(t, "owner@example.com", owner.Email) assert.Equal(t, "owner@example.com", owner.Email)
assert.Empty(t, owner.FullName) assert.Empty(t, owner.FullName)
assert.Equal(t, "Owner", owner.Role) assert.Equal(t, []string{"Owner"}, owner.Roles)
assert.True(t, owner.IsAdmin) assert.True(t, owner.IsAdmin)
assert.Equal(t, "owner@example.com", owner.ExternalID) assert.Equal(t, "owner@example.com", owner.ExternalID)
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, owner.AccountType) assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, owner.AccountType)
@@ -62,7 +62,7 @@ func TestSendGridDriver(t *testing.T) {
teammate := records[1] teammate := records[1]
assert.Equal(t, "taylor@example.com", teammate.Email) assert.Equal(t, "taylor@example.com", teammate.Email)
assert.Equal(t, "Taylor Teammate", teammate.FullName) assert.Equal(t, "Taylor Teammate", teammate.FullName)
assert.Equal(t, "Teammate", teammate.Role) assert.Equal(t, []string{"Teammate"}, teammate.Roles)
assert.False(t, teammate.IsAdmin) assert.False(t, teammate.IsAdmin)
// Non-unified teammate: username is a handle distinct from the email. // Non-unified teammate: username is a handle distinct from the email.
assert.Equal(t, "taylor-teammate", teammate.ExternalID) assert.Equal(t, "taylor-teammate", teammate.ExternalID)
@@ -70,27 +70,27 @@ func TestSendGridDriver(t *testing.T) {
assert.Equal(t, coredata.MFAStatusEnabled, teammate.MFAStatus) assert.Equal(t, coredata.MFAStatusEnabled, teammate.MFAStatus)
} }
func TestSendGridRole(t *testing.T) { func TestSendGridRoles(t *testing.T) {
t.Parallel() t.Parallel()
tests := []struct { tests := []struct {
name string name string
userType string userType string
isAdmin bool isAdmin bool
want string want []string
}{ }{
{name: "owner", userType: "owner", isAdmin: true, want: "Owner"}, {name: "owner", userType: "owner", isAdmin: true, want: []string{"Owner"}},
{name: "admin", userType: "admin", isAdmin: true, want: "Admin"}, {name: "admin", userType: "admin", isAdmin: true, want: []string{"Admin"}},
{name: "teammate", userType: "teammate", isAdmin: false, want: "Teammate"}, {name: "teammate", userType: "teammate", isAdmin: false, want: []string{"Teammate"}},
{name: "empty admin", userType: "", isAdmin: true, want: "Admin"}, {name: "empty admin", userType: "", isAdmin: true, want: []string{"Admin"}},
{name: "empty teammate", userType: "", isAdmin: false, want: "Teammate"}, {name: "empty teammate", userType: "", isAdmin: false, want: []string{"Teammate"}},
{name: "unknown", userType: "custom-role", isAdmin: false, want: "custom-role"}, {name: "unknown", userType: "custom-role", isAdmin: false, want: []string{"custom-role"}},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel() t.Parallel()
assert.Equal(t, tt.want, sendGridRole(tt.userType, tt.isAdmin)) assert.Equal(t, tt.want, sendGridRoles(tt.userType, tt.isAdmin))
}) })
} }
} }

View File

@@ -21,6 +21,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"strings"
"time" "time"
"go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/coredata"
@@ -120,7 +121,8 @@ func (d *SentryDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
active = active && m.User.IsActive active = active && m.User.IsActive
} }
isAdmin := m.OrgRole == "admin" || m.OrgRole == "owner" role := strings.TrimSpace(m.OrgRole)
isAdmin := role == "admin" || role == "owner"
mfaStatus := coredata.MFAStatusUnknown mfaStatus := coredata.MFAStatusUnknown
@@ -134,10 +136,15 @@ func (d *SentryDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
authMethod := sentryAuthMethod(m.Flags, m.User) authMethod := sentryAuthMethod(m.Flags, m.User)
roles := []string{}
if role != "" {
roles = []string{role}
}
record := AccountRecord{ record := AccountRecord{
Email: m.Email, Email: m.Email,
FullName: fullName, FullName: fullName,
Role: m.OrgRole, Roles: roles,
Active: new(active), Active: new(active),
IsAdmin: isAdmin, IsAdmin: isAdmin,
ExternalID: m.ID, ExternalID: m.ID,

View File

@@ -45,7 +45,7 @@ func TestSentryDriver(t *testing.T) {
assert.NotEmpty(t, r.Email) assert.NotEmpty(t, r.Email)
assert.NotEmpty(t, r.FullName) assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.ExternalID) assert.NotEmpty(t, r.ExternalID)
assert.NotEmpty(t, r.Role) assert.NotEmpty(t, r.Roles)
} }
func TestSentryDriverListAccountsStaleSlug(t *testing.T) { func TestSentryDriverListAccountsStaleSlug(t *testing.T) {

View File

@@ -20,6 +20,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"slices"
"strings" "strings"
"time" "time"
@@ -77,14 +78,14 @@ func (d *SigNozDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
continue continue
} }
role := sigNozRole(u.Role) roles := sigNozRoles(u.Role)
record := AccountRecord{ record := AccountRecord{
Email: email, Email: email,
FullName: strings.TrimSpace(u.DisplayName), FullName: strings.TrimSpace(u.DisplayName),
Role: role, Roles: roles,
Active: sigNozActiveStatus(u.Status), Active: sigNozActiveStatus(u.Status),
IsAdmin: u.IsRoot || strings.EqualFold(role, "Admin"), IsAdmin: u.IsRoot || slices.Contains(roles, "Admin"),
MFAStatus: coredata.MFAStatusUnknown, MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser, AccountType: coredata.AccessReviewEntryAccountTypeUser,
@@ -146,26 +147,26 @@ func (d *SigNozDriver) queryUsers(ctx context.Context) ([]sigNozUser, error) {
return users, nil return users, nil
} }
// sigNozRole normalizes a SigNoz role string (ADMIN / EDITOR / VIEWER, or the // sigNozRoles normalizes a SigNoz role string (ADMIN / EDITOR / VIEWER, or the
// managed-role display names signoz-admin / signoz-editor / signoz-viewer) // managed-role display names signoz-admin / signoz-editor / signoz-viewer)
// into a stable label, preserving unknown custom roles verbatim. Matching is // into a stable label, preserving unknown custom roles verbatim. Matching is
// exact (not substring) so a custom role merely containing "admin" is not // exact (not substring) so a custom role merely containing "admin" is not
// silently promoted to Admin. // silently promoted to Admin.
func sigNozRole(raw string) string { func sigNozRoles(raw string) []string {
role := strings.TrimSpace(raw) role := strings.TrimSpace(raw)
if role == "" { if role == "" {
return "User" return []string{}
} }
switch strings.ToLower(role) { switch strings.ToLower(role) {
case "admin", "signoz-admin": case "admin", "signoz-admin":
return "Admin" return []string{"Admin"}
case "editor", "signoz-editor": case "editor", "signoz-editor":
return "Editor" return []string{"Editor"}
case "viewer", "signoz-viewer": case "viewer", "signoz-viewer":
return "Viewer" return []string{"Viewer"}
default: default:
return role return []string{role}
} }
} }

View File

@@ -47,7 +47,7 @@ func TestSigNozDriver(t *testing.T) {
// ADMIN role -> admin. // ADMIN role -> admin.
assert.Equal(t, "admin@example.com", records[0].Email) assert.Equal(t, "admin@example.com", records[0].Email)
assert.Equal(t, "Admin User", records[0].FullName) assert.Equal(t, "Admin User", records[0].FullName)
assert.Equal(t, "Admin", records[0].Role) assert.Equal(t, []string{"Admin"}, records[0].Roles)
assert.True(t, records[0].IsAdmin) assert.True(t, records[0].IsAdmin)
assert.Equal(t, "00000000-0000-4000-8000-000000000001", records[0].ExternalID) assert.Equal(t, "00000000-0000-4000-8000-000000000001", records[0].ExternalID)
assert.Equal(t, coredata.MFAStatusUnknown, records[0].MFAStatus) assert.Equal(t, coredata.MFAStatusUnknown, records[0].MFAStatus)
@@ -57,25 +57,25 @@ func TestSigNozDriver(t *testing.T) {
// isRoot -> admin even with a non-admin role. // isRoot -> admin even with a non-admin role.
assert.Equal(t, "owner@example.com", records[1].Email) assert.Equal(t, "owner@example.com", records[1].Email)
assert.Equal(t, "Viewer", records[1].Role) assert.Equal(t, []string{"Viewer"}, records[1].Roles)
assert.True(t, records[1].IsAdmin) assert.True(t, records[1].IsAdmin)
// Managed-role display name -> Editor; not admin. // Managed-role display name -> Editor; not admin.
assert.Equal(t, "editor@example.com", records[2].Email) assert.Equal(t, "editor@example.com", records[2].Email)
assert.Equal(t, "Editor", records[2].Role) assert.Equal(t, []string{"Editor"}, records[2].Roles)
assert.False(t, records[2].IsAdmin) assert.False(t, records[2].IsAdmin)
require.NotNil(t, records[2].Active) require.NotNil(t, records[2].Active)
assert.True(t, *records[2].Active) assert.True(t, *records[2].Active)
// pending_invite -> inactive. // pending_invite -> inactive.
assert.Equal(t, "invited@example.com", records[3].Email) assert.Equal(t, "invited@example.com", records[3].Email)
assert.Equal(t, "Viewer", records[3].Role) assert.Equal(t, []string{"Viewer"}, records[3].Roles)
require.NotNil(t, records[3].Active) require.NotNil(t, records[3].Active)
assert.False(t, *records[3].Active) assert.False(t, *records[3].Active)
// deleted -> inactive. // deleted -> inactive.
assert.Equal(t, "removed@example.com", records[4].Email) assert.Equal(t, "removed@example.com", records[4].Email)
assert.Equal(t, "Editor", records[4].Role) assert.Equal(t, []string{"Editor"}, records[4].Roles)
require.NotNil(t, records[4].Active) require.NotNil(t, records[4].Active)
assert.False(t, *records[4].Active) assert.False(t, *records[4].Active)
@@ -121,22 +121,22 @@ func TestSigNozDriverListAccountsErrorStatus(t *testing.T) {
assert.Contains(t, err.Error(), "unexpected status 403") assert.Contains(t, err.Error(), "unexpected status 403")
} }
func TestSigNozRole(t *testing.T) { func TestSigNozRoles(t *testing.T) {
t.Parallel() t.Parallel()
for in, want := range map[string]string{ for in, want := range map[string][]string{
"ADMIN": "Admin", "ADMIN": {"Admin"},
"signoz-admin": "Admin", "signoz-admin": {"Admin"},
"EDITOR": "Editor", "EDITOR": {"Editor"},
"signoz-editor": "Editor", "signoz-editor": {"Editor"},
"VIEWER": "Viewer", "VIEWER": {"Viewer"},
"signoz-viewer": "Viewer", "signoz-viewer": {"Viewer"},
"": "User", "": {},
" ": "User", " ": {},
"custom-role": "custom-role", // unknown role preserved verbatim "custom-role": {"custom-role"}, // unknown role preserved verbatim
"superadmin": "superadmin", // contains "admin" but must NOT be promoted "superadmin": {"superadmin"}, // contains "admin" but must NOT be promoted
} { } {
assert.Equalf(t, want, sigNozRole(in), "role %q", in) assert.Equalf(t, want, sigNozRoles(in), "role %q", in)
} }
} }

View File

@@ -100,7 +100,7 @@ func (d *SlackDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error)
Email: m.Profile.Email, Email: m.Profile.Email,
FullName: m.RealName, FullName: m.RealName,
JobTitle: m.Profile.Title, JobTitle: m.Profile.Title,
Role: slackRole(m), Roles: slackRoles(m),
Active: new(!m.Deleted), Active: new(!m.Deleted),
IsAdmin: m.IsAdmin || m.IsOwner || m.IsPrimaryOwner, IsAdmin: m.IsAdmin || m.IsOwner || m.IsPrimaryOwner,
ExternalID: m.ID, ExternalID: m.ID,
@@ -163,20 +163,20 @@ func (d *SlackDriver) queryUsers(ctx context.Context, cursor string) (*slackUser
return &resp, nil return &resp, nil
} }
func slackRole(m slackMember) string { func slackRoles(m slackMember) []string {
switch { switch {
case m.IsPrimaryOwner: case m.IsPrimaryOwner:
return "Primary Owner" return []string{"Primary Owner"}
case m.IsOwner: case m.IsOwner:
return "Owner" return []string{"Owner"}
case m.IsAdmin: case m.IsAdmin:
return "Admin" return []string{"Admin"}
case m.IsUltraRestricted: case m.IsUltraRestricted:
return "Ultra Restricted" return []string{"Ultra Restricted"}
case m.IsRestricted: case m.IsRestricted:
return "Restricted" return []string{"Restricted"}
default: default:
return "Member" return []string{"Member"}
} }
} }

View File

@@ -46,5 +46,5 @@ func TestSlackDriver(t *testing.T) {
require.NotEmpty(t, r.Email, "expected at least one record with an email") require.NotEmpty(t, r.Email, "expected at least one record with an email")
assert.NotEmpty(t, r.ExternalID) assert.NotEmpty(t, r.ExternalID)
assert.NotEmpty(t, r.Role) assert.NotEmpty(t, r.Roles)
} }

View File

@@ -20,6 +20,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"strings"
"go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/coredata"
) )
@@ -62,10 +63,17 @@ func (d *SupabaseDriver) ListAccounts(ctx context.Context) ([]AccountRecord, err
isAdmin := m.RoleName == "Owner" || m.RoleName == "Administrator" isAdmin := m.RoleName == "Owner" || m.RoleName == "Administrator"
role := strings.TrimSpace(m.RoleName)
roles := []string{}
if role != "" {
roles = []string{role}
}
record := AccountRecord{ record := AccountRecord{
Email: m.Email, Email: m.Email,
FullName: m.UserName, FullName: m.UserName,
Role: m.RoleName, Roles: roles,
IsAdmin: isAdmin, IsAdmin: isAdmin,
ExternalID: m.UserID, ExternalID: m.UserID,
MFAStatus: mfaStatus, MFAStatus: mfaStatus,

View File

@@ -42,5 +42,5 @@ func TestSupabaseDriver(t *testing.T) {
r := records[0] r := records[0]
assert.NotEmpty(t, r.FullName) assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.ExternalID) assert.NotEmpty(t, r.ExternalID)
assert.NotEmpty(t, r.Role) assert.NotEmpty(t, r.Roles)
} }

View File

@@ -75,10 +75,17 @@ func (d *TailscaleDriver) ListAccounts(ctx context.Context) ([]AccountRecord, er
continue continue
} }
role := strings.TrimSpace(u.Role)
roles := []string{}
if role != "" {
roles = []string{role}
}
record := AccountRecord{ record := AccountRecord{
Email: email, Email: email,
FullName: u.DisplayName, FullName: u.DisplayName,
Role: u.Role, Roles: roles,
Active: tailscaleUserActive(u.Status), Active: tailscaleUserActive(u.Status),
IsAdmin: tailscaleUserIsAdmin(u.Role), IsAdmin: tailscaleUserIsAdmin(u.Role),
ExternalID: u.ID, ExternalID: u.ID,

View File

@@ -37,6 +37,6 @@ func TestTailscaleDriver(t *testing.T) {
r := records[0] r := records[0]
assert.NotEmpty(t, r.Email) assert.NotEmpty(t, r.Email)
assert.NotEmpty(t, r.ExternalID) assert.NotEmpty(t, r.ExternalID)
assert.NotEmpty(t, r.Role) assert.NotEmpty(t, r.Roles)
assert.NotNil(t, r.Active) assert.NotNil(t, r.Active)
} }

View File

@@ -178,7 +178,7 @@ func (d *TallyDriver) listInvites(ctx context.Context) ([]AccountRecord, error)
MFAStatus: coredata.MFAStatusUnknown, MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser, AccountType: coredata.AccessReviewEntryAccountTypeUser,
Role: "Invited", Roles: tallyRoles(),
} }
if record.Email != "" { if record.Email != "" {
@@ -188,3 +188,7 @@ func (d *TallyDriver) listInvites(ctx context.Context) ([]AccountRecord, error)
return records, nil return records, nil
} }
func tallyRoles() []string {
return []string{"Invited"}
}

View File

@@ -21,6 +21,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"strings"
"go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/coredata"
) )
@@ -92,11 +93,18 @@ func (d *VercelDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
fullName = m.Username fullName = m.Username
} }
role := strings.TrimSpace(m.Role)
roles := []string{}
if role != "" {
roles = []string{role}
}
confirmed := m.Confirmed confirmed := m.Confirmed
record := AccountRecord{ record := AccountRecord{
Email: m.Email, Email: m.Email,
FullName: fullName, FullName: fullName,
Role: m.Role, Roles: roles,
Active: &confirmed, Active: &confirmed,
IsAdmin: m.Role == "OWNER" || m.Role == "owner", IsAdmin: m.Role == "OWNER" || m.Role == "owner",
MFAStatus: coredata.MFAStatusUnknown, MFAStatus: coredata.MFAStatusUnknown,

View File

@@ -43,7 +43,7 @@ func TestVercelDriver(t *testing.T) {
assert.NotEmpty(t, r.Email) assert.NotEmpty(t, r.Email)
assert.NotEmpty(t, r.ExternalID) assert.NotEmpty(t, r.ExternalID)
assert.NotEmpty(t, r.FullName) assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.Role) assert.NotEmpty(t, r.Roles)
assert.True(t, r.IsAdmin) assert.True(t, r.IsAdmin)
require.NotNil(t, r.Active) require.NotNil(t, r.Active)
assert.True(t, *r.Active) assert.True(t, *r.Active)

View File

@@ -21,6 +21,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"strings"
"go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/coredata"
) )
@@ -129,10 +130,17 @@ func zendeskRecord(u zendeskUser) AccountRecord {
lastLogin = *u.LastLoginAt lastLogin = *u.LastLoginAt
} }
role := strings.TrimSpace(u.Role)
roles := []string{}
if role != "" {
roles = []string{role}
}
return AccountRecord{ return AccountRecord{
Email: u.Email, Email: u.Email,
FullName: u.Name, FullName: u.Name,
Role: u.Role, Roles: roles,
Active: &active, Active: &active,
IsAdmin: isAdmin, IsAdmin: isAdmin,
MFAStatus: mfaStatus, MFAStatus: mfaStatus,

View File

@@ -44,7 +44,7 @@ func TestZendeskDriver(t *testing.T) {
require.NotNil(t, r.Active) require.NotNil(t, r.Active)
assert.True(t, *r.Active) assert.True(t, *r.Active)
assert.True(t, r.IsAdmin) assert.True(t, r.IsAdmin)
assert.Equal(t, "admin", r.Role) assert.Equal(t, []string{"admin"}, r.Roles)
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, r.AccountType) assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, r.AccountType)
assert.Equal(t, coredata.MFAStatusEnabled, r.MFAStatus) assert.Equal(t, coredata.MFAStatusEnabled, r.MFAStatus)
assert.Equal(t, coredata.AccessReviewEntryAuthMethodUnknown, r.AuthMethod) assert.Equal(t, coredata.AccessReviewEntryAuthMethodUnknown, r.AuthMethod)
@@ -59,7 +59,7 @@ func TestZendeskDriver(t *testing.T) {
require.NotNil(t, r2.Active) require.NotNil(t, r2.Active)
assert.True(t, *r2.Active) assert.True(t, *r2.Active)
assert.False(t, r2.IsAdmin) assert.False(t, r2.IsAdmin)
assert.Equal(t, "agent", r2.Role) assert.Equal(t, []string{"agent"}, r2.Roles)
assert.Equal(t, coredata.MFAStatusDisabled, r2.MFAStatus) assert.Equal(t, coredata.MFAStatusDisabled, r2.MFAStatus)
assert.Nil(t, r2.LastLogin) assert.Nil(t, r2.LastLogin)
} }
@@ -85,7 +85,7 @@ func TestZendeskRecord_FieldMapping(t *testing.T) {
require.NotNil(t, rec.Active) require.NotNil(t, rec.Active)
assert.False(t, *rec.Active, "a suspended user must be inactive") assert.False(t, *rec.Active, "a suspended user must be inactive")
assert.Equal(t, coredata.MFAStatusUnknown, rec.MFAStatus, "null 2FA must stay unknown") assert.Equal(t, coredata.MFAStatusUnknown, rec.MFAStatus, "null 2FA must stay unknown")
assert.Equal(t, "Light agent", rec.Role, "custom role names pass through") assert.Equal(t, []string{"Light agent"}, rec.Roles, "custom role names pass through")
assert.False(t, rec.IsAdmin) assert.False(t, rec.IsAdmin)
assert.Equal(t, "42", rec.ExternalID) assert.Equal(t, "42", rec.ExternalID)
assert.Nil(t, rec.LastLogin) assert.Nil(t, rec.LastLogin)

View File

@@ -131,7 +131,7 @@ func (s *Service) FetchSource(
AccessReviewCampaignSourceID: campaignSource.ID, AccessReviewCampaignSourceID: campaignSource.ID,
Email: account.Email, Email: account.Email,
FullName: account.FullName, FullName: account.FullName,
Role: account.Role, Roles: account.Roles,
JobTitle: account.JobTitle, JobTitle: account.JobTitle,
IsAdmin: account.IsAdmin, IsAdmin: account.IsAdmin,
MFAStatus: account.MFAStatus, MFAStatus: account.MFAStatus,

View File

@@ -41,7 +41,7 @@ func insertAccessReviewEntry(t *testing.T, ctx context.Context, client *pg.Clien
AccessReviewCampaignSourceID: fx.campaignSourceID, AccessReviewCampaignSourceID: fx.campaignSourceID,
Email: accountKey, Email: accountKey,
FullName: "Snapshot User", FullName: "Snapshot User",
Role: "member", Roles: []string{"member"},
MFAStatus: coredata.MFAStatusUnknown, MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser, AccountType: coredata.AccessReviewEntryAccountTypeUser,

View File

@@ -37,7 +37,7 @@ type (
IdentityID *gid.GID `db:"identity_id"` IdentityID *gid.GID `db:"identity_id"`
Email string `db:"email"` Email string `db:"email"`
FullName string `db:"full_name"` FullName string `db:"full_name"`
Role string `db:"role"` Roles []string `db:"roles"`
JobTitle string `db:"job_title"` JobTitle string `db:"job_title"`
IsAdmin bool `db:"is_admin"` IsAdmin bool `db:"is_admin"`
MFAStatus MFAStatus `db:"mfa_status"` MFAStatus MFAStatus `db:"mfa_status"`
@@ -125,7 +125,7 @@ SELECT
identity_id, identity_id,
email, email,
full_name, full_name,
role, roles,
job_title, job_title,
is_admin, is_admin,
mfa_status, mfa_status,
@@ -192,7 +192,7 @@ INSERT INTO
identity_id, identity_id,
email, email,
full_name, full_name,
role, roles,
job_title, job_title,
is_admin, is_admin,
mfa_status, mfa_status,
@@ -222,7 +222,7 @@ VALUES (
@identity_id, @identity_id,
@email, @email,
@full_name, @full_name,
@role, COALESCE(@roles, '{}'::TEXT[]),
@job_title, @job_title,
@is_admin, @is_admin,
@mfa_status, @mfa_status,
@@ -254,7 +254,7 @@ VALUES (
"identity_id": e.IdentityID, "identity_id": e.IdentityID,
"email": e.Email, "email": e.Email,
"full_name": e.FullName, "full_name": e.FullName,
"role": e.Role, "roles": e.Roles,
"job_title": e.JobTitle, "job_title": e.JobTitle,
"is_admin": e.IsAdmin, "is_admin": e.IsAdmin,
"mfa_status": e.MFAStatus, "mfa_status": e.MFAStatus,
@@ -346,7 +346,7 @@ SELECT
identity_id, identity_id,
email, email,
full_name, full_name,
role, roles,
job_title, job_title,
is_admin, is_admin,
mfa_status, mfa_status,
@@ -414,7 +414,7 @@ SELECT
identity_id, identity_id,
email, email,
full_name, full_name,
role, roles,
job_title, job_title,
is_admin, is_admin,
mfa_status, mfa_status,
@@ -630,7 +630,7 @@ INSERT INTO access_review_entries (
identity_id, identity_id,
email, email,
full_name, full_name,
role, roles,
job_title, job_title,
is_admin, is_admin,
mfa_status, mfa_status,
@@ -659,7 +659,7 @@ INSERT INTO access_review_entries (
@identity_id, @identity_id,
@email, @email,
@full_name, @full_name,
@role, COALESCE(@roles, '{}'::TEXT[]),
@job_title, @job_title,
@is_admin, @is_admin,
@mfa_status, @mfa_status,
@@ -683,7 +683,7 @@ INSERT INTO access_review_entries (
ON CONFLICT (access_review_campaign_source_id, account_key) DO UPDATE SET ON CONFLICT (access_review_campaign_source_id, account_key) DO UPDATE SET
email = EXCLUDED.email, email = EXCLUDED.email,
full_name = EXCLUDED.full_name, full_name = EXCLUDED.full_name,
role = EXCLUDED.role, roles = EXCLUDED.roles,
job_title = EXCLUDED.job_title, job_title = EXCLUDED.job_title,
is_admin = EXCLUDED.is_admin, is_admin = EXCLUDED.is_admin,
mfa_status = EXCLUDED.mfa_status, mfa_status = EXCLUDED.mfa_status,
@@ -706,7 +706,7 @@ ON CONFLICT (access_review_campaign_source_id, account_key) DO UPDATE SET
"identity_id": e.IdentityID, "identity_id": e.IdentityID,
"email": e.Email, "email": e.Email,
"full_name": e.FullName, "full_name": e.FullName,
"role": e.Role, "roles": e.Roles,
"job_title": e.JobTitle, "job_title": e.JobTitle,
"is_admin": e.IsAdmin, "is_admin": e.IsAdmin,
"mfa_status": e.MFAStatus, "mfa_status": e.MFAStatus,

View File

@@ -19,6 +19,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.gearno.de/kit/pg" "go.gearno.de/kit/pg"
@@ -152,7 +153,7 @@ func TestAccessReviewEntry_Upsert_FreezesDecidedFields(t *testing.T) {
originalFlags := []coredata.AccessReviewEntryFlag{coredata.AccessReviewEntryFlagNew} originalFlags := []coredata.AccessReviewEntryFlag{coredata.AccessReviewEntryFlagNew}
originalEmail := "old@example.com" originalEmail := "old@example.com"
originalFullName := "Old Name" originalFullName := "Old Name"
originalRole := "viewer" originalRoles := []string{"viewer"}
t0 := time.Now().UTC().Truncate(time.Microsecond) t0 := time.Now().UTC().Truncate(time.Microsecond)
@@ -165,7 +166,7 @@ func TestAccessReviewEntry_Upsert_FreezesDecidedFields(t *testing.T) {
AccessReviewCampaignSourceID: fx.campaignSourceID, AccessReviewCampaignSourceID: fx.campaignSourceID,
Email: originalEmail, Email: originalEmail,
FullName: originalFullName, FullName: originalFullName,
Role: originalRole, Roles: originalRoles,
JobTitle: "", JobTitle: "",
IsAdmin: false, IsAdmin: false,
MFAStatus: coredata.MFAStatusUnknown, MFAStatus: coredata.MFAStatusUnknown,
@@ -214,7 +215,7 @@ func TestAccessReviewEntry_Upsert_FreezesDecidedFields(t *testing.T) {
t2 := decisionTime.Add(1 * time.Hour) t2 := decisionTime.Add(1 * time.Hour)
secondEmail := "new@example.com" secondEmail := "new@example.com"
secondFullName := "New Name" secondFullName := "New Name"
secondRole := "admin" secondRoles := []string{"admin"}
refresh := &coredata.AccessReviewEntry{ refresh := &coredata.AccessReviewEntry{
ID: gid.New(tenantID, coredata.AccessReviewEntryEntityType), // ignored by ON CONFLICT ID: gid.New(tenantID, coredata.AccessReviewEntryEntityType), // ignored by ON CONFLICT
OrganizationID: fx.organizationID, OrganizationID: fx.organizationID,
@@ -222,7 +223,7 @@ func TestAccessReviewEntry_Upsert_FreezesDecidedFields(t *testing.T) {
AccessReviewCampaignSourceID: fx.campaignSourceID, AccessReviewCampaignSourceID: fx.campaignSourceID,
Email: secondEmail, Email: secondEmail,
FullName: secondFullName, FullName: secondFullName,
Role: secondRole, Roles: secondRoles,
JobTitle: "", JobTitle: "",
IsAdmin: true, IsAdmin: true,
MFAStatus: coredata.MFAStatusEnabled, MFAStatus: coredata.MFAStatusEnabled,
@@ -271,7 +272,7 @@ func TestAccessReviewEntry_Upsert_FreezesDecidedFields(t *testing.T) {
// Columns that ARE refreshed on every poll. // Columns that ARE refreshed on every poll.
assert.Equal(t, secondEmail, loaded.Email) assert.Equal(t, secondEmail, loaded.Email)
assert.Equal(t, secondFullName, loaded.FullName) assert.Equal(t, secondFullName, loaded.FullName)
assert.Equal(t, secondRole, loaded.Role) assert.Equal(t, secondRoles, loaded.Roles)
assert.True(t, loaded.IsAdmin) assert.True(t, loaded.IsAdmin)
assert.Equal(t, coredata.MFAStatusEnabled, loaded.MFAStatus) assert.Equal(t, coredata.MFAStatusEnabled, loaded.MFAStatus)
assert.Equal(t, coredata.AccessReviewEntryAuthMethodSSO, loaded.AuthMethod) assert.Equal(t, coredata.AccessReviewEntryAuthMethodSSO, loaded.AuthMethod)
@@ -303,7 +304,7 @@ func TestAccessReviewEntry_Upsert_RefreshesSourceTrackingFields(t *testing.T) {
AccessReviewCampaignSourceID: fx.campaignSourceID, AccessReviewCampaignSourceID: fx.campaignSourceID,
Email: "old@example.com", Email: "old@example.com",
FullName: "Old Name", FullName: "Old Name",
Role: "viewer", Roles: []string{"viewer"},
MFAStatus: coredata.MFAStatusUnknown, MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser, AccountType: coredata.AccessReviewEntryAccountTypeUser,
@@ -329,7 +330,7 @@ func TestAccessReviewEntry_Upsert_RefreshesSourceTrackingFields(t *testing.T) {
AccessReviewCampaignSourceID: fx.campaignSourceID, AccessReviewCampaignSourceID: fx.campaignSourceID,
Email: "new@example.com", Email: "new@example.com",
FullName: "New Name", FullName: "New Name",
Role: "admin", Roles: []string{"admin"},
MFAStatus: coredata.MFAStatusEnabled, MFAStatus: coredata.MFAStatusEnabled,
AuthMethod: coredata.AccessReviewEntryAuthMethodSSO, AuthMethod: coredata.AccessReviewEntryAuthMethodSSO,
AccountType: coredata.AccessReviewEntryAccountTypeUser, AccountType: coredata.AccessReviewEntryAccountTypeUser,
@@ -356,7 +357,7 @@ func TestAccessReviewEntry_Upsert_RefreshesSourceTrackingFields(t *testing.T) {
// Source-tracking columns advanced to the second poll's values. // Source-tracking columns advanced to the second poll's values.
assert.Equal(t, "new@example.com", loaded.Email) assert.Equal(t, "new@example.com", loaded.Email)
assert.Equal(t, "New Name", loaded.FullName) assert.Equal(t, "New Name", loaded.FullName)
assert.Equal(t, "admin", loaded.Role) assert.Equal(t, []string{"admin"}, loaded.Roles)
assert.Equal(t, coredata.MFAStatusEnabled, loaded.MFAStatus) assert.Equal(t, coredata.MFAStatusEnabled, loaded.MFAStatus)
assert.Equal(t, coredata.AccessReviewEntryAuthMethodSSO, loaded.AuthMethod) assert.Equal(t, coredata.AccessReviewEntryAuthMethodSSO, loaded.AuthMethod)
@@ -393,7 +394,7 @@ func TestAccessReviewEntry_Upsert_InsertsActiveAccount(t *testing.T) {
AccessReviewCampaignSourceID: fx.campaignSourceID, AccessReviewCampaignSourceID: fx.campaignSourceID,
Email: "active@example.com", Email: "active@example.com",
FullName: "Active User", FullName: "Active User",
Role: "member", Roles: []string{"member"},
MFAStatus: coredata.MFAStatusUnknown, MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser, AccountType: coredata.AccessReviewEntryAccountTypeUser,
@@ -424,3 +425,52 @@ func TestAccessReviewEntry_Upsert_InsertsActiveAccount(t *testing.T) {
assert.Nil(t, loaded.DecidedBy) assert.Nil(t, loaded.DecidedBy)
assert.Nil(t, loaded.DecidedAt) assert.Nil(t, loaded.DecidedAt)
} }
func TestAccessReviewEntry_Upsert_NilRolesWritesEmptyArray(t *testing.T) {
t.Parallel()
client := test.PGClient(t)
ctx := context.Background()
fx := seedAccessReviewEntryFixture(t, ctx, client)
tenantID := fx.scope.GetTenantID()
t0 := time.Now().UTC().Truncate(time.Microsecond)
entryID := gid.New(tenantID, coredata.AccessReviewEntryEntityType)
entry := &coredata.AccessReviewEntry{
ID: entryID,
OrganizationID: fx.organizationID,
AccessReviewCampaignID: fx.campaignID,
AccessReviewCampaignSourceID: fx.campaignSourceID,
Email: "nil-roles@example.com",
FullName: "Nil Roles User",
Roles: nil,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
ExternalID: "ext-nil-roles",
AccountKey: "nil-roles@example.com",
IncrementalTag: coredata.AccessReviewEntryIncrementalTagNew,
Flags: []coredata.AccessReviewEntryFlag{},
FlagReasons: []string{},
Decision: coredata.AccessReviewEntryDecisionPending,
CreatedAt: t0,
UpdatedAt: t0,
}
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
return entry.Upsert(ctx, tx, fx.scope)
}))
var roles []string
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
return conn.QueryRow(
ctx,
`SELECT roles FROM access_review_entries WHERE id = @id`,
pgx.StrictNamedArgs{"id": entryID},
).Scan(&roles)
}))
assert.Equal(t, []string{}, roles)
}

View File

@@ -0,0 +1,26 @@
-- Copyright (c) 2026 Probo Inc <hello@probo.com>.
--
-- Permission to use, copy, modify, and/or distribute this software for any
-- purpose with or without fee is hereby granted, provided that the above
-- copyright notice and this permission notice appear in all copies.
--
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
-- PERFORMANCE OF THIS SOFTWARE.
ALTER TABLE access_review_entries
ADD COLUMN roles TEXT[] NOT NULL DEFAULT '{}';
UPDATE access_review_entries
SET roles = CASE
WHEN role = '' THEN '{}'::TEXT[]
WHEN position(', ' in role) > 0 THEN string_to_array(role, ', ')
ELSE ARRAY[role]
END;
ALTER TABLE access_review_entries
DROP COLUMN role;

View File

@@ -377,7 +377,7 @@ type AccessReviewEntry implements Node {
campaignSource: AccessReviewCampaignSource! @goField(forceResolver: true) campaignSource: AccessReviewCampaignSource! @goField(forceResolver: true)
email: String! email: String!
fullName: String! fullName: String!
role: String! roles: [String!]!
jobTitle: String! jobTitle: String!
isAdmin: Boolean! isAdmin: Boolean!
active: Boolean active: Boolean

View File

@@ -278,6 +278,11 @@ func NewAccessReviewEntryEdge(e *coredata.AccessReviewEntry, orderBy coredata.Ac
} }
func NewAccessReviewEntry(e *coredata.AccessReviewEntry) *AccessReviewEntry { func NewAccessReviewEntry(e *coredata.AccessReviewEntry) *AccessReviewEntry {
roles := e.Roles
if roles == nil {
roles = []string{}
}
entry := &AccessReviewEntry{ entry := &AccessReviewEntry{
ID: e.ID, ID: e.ID,
Campaign: &AccessReviewCampaign{ Campaign: &AccessReviewCampaign{
@@ -288,7 +293,7 @@ func NewAccessReviewEntry(e *coredata.AccessReviewEntry) *AccessReviewEntry {
}, },
Email: e.Email, Email: e.Email,
FullName: e.FullName, FullName: e.FullName,
Role: e.Role, Roles: roles,
JobTitle: e.JobTitle, JobTitle: e.JobTitle,
IsAdmin: e.IsAdmin, IsAdmin: e.IsAdmin,
Active: e.Active, Active: e.Active,

View File

@@ -7684,7 +7684,7 @@ components:
- access_review_campaign_source_id - access_review_campaign_source_id
- email - email
- full_name - full_name
- role - roles
- job_title - job_title
- is_admin - is_admin
- mfa_status - mfa_status
@@ -7712,9 +7712,11 @@ components:
full_name: full_name:
type: string type: string
description: User full name description: User full name
role: roles:
type: string type: array
description: User role in the system items:
type: string
description: User roles in the system
job_title: job_title:
type: string type: string
description: User job title description: User job title

View File

@@ -68,13 +68,18 @@ func NewAccessReviewCampaign(c *coredata.AccessReviewCampaign) *AccessReviewCamp
} }
func NewAccessReviewEntry(e *coredata.AccessReviewEntry) *AccessReviewEntry { func NewAccessReviewEntry(e *coredata.AccessReviewEntry) *AccessReviewEntry {
roles := e.Roles
if roles == nil {
roles = []string{}
}
entry := &AccessReviewEntry{ entry := &AccessReviewEntry{
ID: e.ID, ID: e.ID,
CampaignID: e.AccessReviewCampaignID, CampaignID: e.AccessReviewCampaignID,
AccessReviewCampaignSourceID: e.AccessReviewCampaignSourceID, AccessReviewCampaignSourceID: e.AccessReviewCampaignSourceID,
Email: e.Email, Email: e.Email,
FullName: e.FullName, FullName: e.FullName,
Role: e.Role, Roles: roles,
JobTitle: e.JobTitle, JobTitle: e.JobTitle,
IsAdmin: e.IsAdmin, IsAdmin: e.IsAdmin,
Active: e.Active, Active: e.Active,