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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ const addScopeMutation = graphql`
|
|||||||
id
|
id
|
||||||
email
|
email
|
||||||
fullName
|
fullName
|
||||||
role
|
roles
|
||||||
isAdmin
|
isAdmin
|
||||||
mfaStatus
|
mfaStatus
|
||||||
lastLogin
|
lastLogin
|
||||||
|
|||||||
@@ -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}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ 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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 len(user.RoleIDs) > 0 {
|
if _, dup := seen[name]; dup {
|
||||||
return user.RoleIDs[0]
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
return ""
|
seen[name] = struct{}{}
|
||||||
|
roles = append(roles, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.SuperAdmin {
|
||||||
|
if _, dup := seen["Super Admin"]; !dup {
|
||||||
|
roles = append(roles, "Super Admin")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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, ""))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
26
pkg/coredata/migrations/20260612T160000Z.sql
Normal file
26
pkg/coredata/migrations/20260612T160000Z.sql
Normal 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;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: array
|
||||||
|
items:
|
||||||
type: string
|
type: string
|
||||||
description: User role in the system
|
description: User roles in the system
|
||||||
job_title:
|
job_title:
|
||||||
type: string
|
type: string
|
||||||
description: User job title
|
description: User job title
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user