Compare commits

...

6 Commits

Author SHA1 Message Date
vikrantgupta25
f2af5b3697 refactor(authz): use bun models in migration 099, wire oss role get
- migration 099 follows the migration-local row struct pattern: bun
  NewSelect/NewUpdate for the role table reads and backfill writes; the
  openfga store and tuple lookups stay raw like 081/083
- oss provider Get reads the role from the store instead of returning
  unsupported
2026-07-08 18:10:28 +05:30
Vikrant Gupta
d5e72145f9 Merge branch 'main' into platform-pod/issues/2683 2026-07-08 17:51:49 +05:30
vikrantgupta25
3ac9e1d6e1 revert(authz): restore TransactionGroups codecs over the storable split
Role is a bun relation target (UserRole.Role, ServiceAccountRole.Role), so
splitting it into StorableRole/Role cascaded: relations must point at the
bun model, which broke the user-roles join and leaked the storable shape
into user and service account responses. Keep the single Role model with
Scan/Value/MarshalJSON on TransactionGroups; the storable split fits leaf
models only.

This reverts commit 73aa7d32b1 and keeps transaction_test.go deleted.
2026-07-08 17:48:31 +05:30
vikrantgupta25
73aa7d32b1 refactor(authz): split role into domain Role and StorableRole
Replace the Scan/Value/MarshalJSON codecs on TransactionGroups with the
storable pattern: StorableRole is the bun model carrying transaction groups
as raw JSON text, Role is the pure domain/wire type, and
NewStorableRoleFromRole/NewRoleFromStorableRole convert at the store
boundary (nil groups persist as [], reads parse through the validating
constructor). RoleStore and sqlauthzstore speak StorableRole; both
providers convert; handlers and the wire contract are unchanged.
2026-07-08 17:27:43 +05:30
vikrantgupta25
8cbdf311af fix(authz): reconcile role tuples from openfga state, decouple migration 059
- Update and Delete derive their diff/deletion base from the tuples openfga
  actually holds for the role (readAllTuplesForRole) instead of the stored
  JSON record, so every mutation sweeps drift and residue; the record stays
  a display-only artifact written after the tuple write
- ReadTuples restored on the AuthZ interface with plain passthroughs in both
  providers and the ee server
- TransactionGroups.Value marshals unconditionally (nil renders as [] via
  MarshalJSON) instead of returning a nil driver.Value
- migration 059 uses a migration-local role struct and constructor so live
  Role model changes cannot alter its insert; migration 099 drops the manual
  column-exists guard (AddColumn emits IF NOT EXISTS)
2026-07-08 14:35:03 +05:30
vikrantgupta25
81995f54bb feat(authz): store role transaction groups as document of record
Persist a role's transaction groups as JSON on the role row so the role
details page is reconstructed deterministically from SQL instead of being
rebuilt from OpenFGA tuples (which will soon carry opaque hashed telemetry
selectors):

- authtypes: TransactionGroups gains Value/Scan (validated via
  NewTransactionGroups) and MarshalJSON (nil renders as []); NewRole takes
  transactionGroups; NewManagedRoles fills managed docs from the registry;
  RoleWithTransactionGroups removed - Role carries the wire field and the
  AuthZ interface, handler, and OpenAPI responses use *Role; GettableRole
  (without transactionGroups) is the list response
- sqlmigration 099: add role.transaction_groups, backfill custom roles from
  their permission tuples (dual dialect) and managed roles from the registry
- sqlmigration 059: pin insert columns so the live Role model addition does
  not break fresh installs (059 runs before 099)
- ee provider: writes persist the doc alongside FGA tuples (FGA first, SQL
  second, as before); GetWithTransactionGroups reads the doc; the per-type
  ReadTuples fan-out (readAllTuplesForRole) is removed
- audit middleware: log and skip resolved resources carrying a resolution
  error
- frontend: regenerated OpenAPI spec and API types; role list consumers
  retyped to GettableRole; role GET keeps transactionGroups
2026-07-08 13:02:09 +05:30
21 changed files with 465 additions and 268 deletions

View File

@@ -543,6 +543,31 @@ components:
required:
- id
type: object
AuthtypesGettableRole:
properties:
createdAt:
format: date-time
type: string
description:
type: string
id:
type: string
name:
type: string
orgId:
type: string
type:
type: string
updatedAt:
format: date-time
type: string
required:
- id
- name
- description
- type
- orgId
type: object
AuthtypesGettableToken:
properties:
accessToken:
@@ -694,43 +719,6 @@ components:
- detach
type: string
AuthtypesRole:
properties:
createdAt:
format: date-time
type: string
description:
type: string
id:
type: string
name:
type: string
orgId:
type: string
type:
type: string
updatedAt:
format: date-time
type: string
required:
- id
- name
- description
- type
- orgId
type: object
AuthtypesRoleMapping:
properties:
defaultRole:
type: string
groupMappings:
additionalProperties:
type: string
nullable: true
type: object
useRoleAttribute:
type: boolean
type: object
AuthtypesRoleWithTransactionGroups:
properties:
createdAt:
format: date-time
@@ -758,6 +746,18 @@ components:
- orgId
- transactionGroups
type: object
AuthtypesRoleMapping:
properties:
defaultRole:
type: string
groupMappings:
additionalProperties:
type: string
nullable: true
type: object
useRoleAttribute:
type: boolean
type: object
AuthtypesSamlConfig:
properties:
attributeMapping:
@@ -11828,7 +11828,7 @@ paths:
properties:
data:
items:
$ref: '#/components/schemas/AuthtypesRole'
$ref: '#/components/schemas/AuthtypesGettableRole'
type: array
status:
type: string
@@ -12012,7 +12012,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/AuthtypesRoleWithTransactionGroups'
$ref: '#/components/schemas/AuthtypesRole'
status:
type: string
required:

View File

@@ -119,10 +119,6 @@ func (provider *provider) CheckTransactions(ctx context.Context, subject string,
return results, nil
}
func (provider *provider) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, objectType coretypes.Type) ([]*coretypes.Object, error) {
return provider.openfgaServer.ListObjects(ctx, subject, relation, objectType)
}
func (provider *provider) Write(ctx context.Context, additions []*openfgav1.TupleKey, deletions []*openfgav1.TupleKey) error {
return provider.openfgaServer.Write(ctx, additions, deletions)
}
@@ -131,10 +127,6 @@ func (provider *provider) ReadTuples(ctx context.Context, tupleKey *openfgav1.Re
return provider.openfgaServer.ReadTuples(ctx, tupleKey)
}
func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.Role, error) {
return provider.pkgAuthzService.Get(ctx, orgID, id)
}
func (provider *provider) GetByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*authtypes.Role, error) {
return provider.pkgAuthzService.GetByOrgIDAndName(ctx, orgID, name)
}
@@ -183,7 +175,7 @@ func (provider *provider) CreateManagedUserRoleTransactions(ctx context.Context,
return provider.Write(ctx, tuples, nil)
}
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *authtypes.RoleWithTransactionGroups) error {
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
@@ -208,70 +200,31 @@ func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *a
return err
}
if err := provider.store.Create(ctx, role.Role); err != nil {
return err
}
return nil
return provider.store.Create(ctx, role)
}
func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) (*authtypes.Role, error) {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
existingRole, err := provider.store.GetByOrgIDAndName(ctx, role.OrgID, role.Name)
if err != nil {
if !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
}
if existingRole != nil {
return existingRole, nil
}
err = provider.store.Create(ctx, role)
if err != nil {
return nil, err
}
return role, nil
func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.Role, error) {
return provider.store.Get(ctx, orgID, id)
}
func (provider *provider) GetWithTransactionGroups(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.RoleWithTransactionGroups, error) {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
role, err := provider.store.Get(ctx, orgID, id)
if err != nil {
return nil, err
}
tuples, err := provider.readAllTuplesForRole(ctx, role.Name, orgID)
if err != nil {
return nil, err
}
transactionGroups := authtypes.MustNewTransactionGroupsFromTuples(tuples)
return authtypes.MakeRoleWithTransactionGroups(role, transactionGroups), nil
}
func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updatedRole *authtypes.RoleWithTransactionGroups) error {
func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updatedRole *authtypes.Role) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
existingRole, err := provider.GetWithTransactionGroups(ctx, orgID, updatedRole.ID)
existingRole, err := provider.Get(ctx, orgID, updatedRole.ID)
if err != nil {
return err
}
additions, deletions := existingRole.TransactionGroups.Diff(updatedRole.TransactionGroups)
existingTuples, err := provider.readAllTuplesForRole(ctx, existingRole.Name, orgID)
if err != nil {
return err
}
existingGroups := authtypes.MustNewTransactionGroupsFromTuples(existingTuples)
additions, deletions := existingGroups.Diff(updatedRole.TransactionGroups)
additionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, additions)
if err != nil {
return err
@@ -287,7 +240,7 @@ func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updated
return err
}
return provider.store.Update(ctx, orgID, updatedRole.Role)
return provider.store.Update(ctx, orgID, updatedRole)
}
func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
@@ -296,7 +249,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
role, err := provider.GetWithTransactionGroups(ctx, orgID, id)
role, err := provider.Get(ctx, orgID, id)
if err != nil {
return err
}
@@ -312,7 +265,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
}
}
tuples, err := authtypes.NewTuplesFromTransactionGroups(role.Name, orgID, role.TransactionGroups)
tuples, err := provider.readAllTuplesForRole(ctx, role.Name, orgID)
if err != nil {
return err
}
@@ -324,6 +277,24 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
return provider.store.Delete(ctx, orgID, id)
}
func (provider *provider) readAllTuplesForRole(ctx context.Context, roleName string, orgID valuer.UUID) ([]*openfgav1.TupleKey, error) {
subject := authtypes.MustNewSubject(coretypes.NewResourceRole(), roleName, orgID, &coretypes.VerbAssignee)
tuples := make([]*openfgav1.TupleKey, 0)
for _, objectType := range provider.registry.Types() {
typeTuples, err := provider.openfgaServer.ReadTuples(ctx, &openfgav1.ReadRequestTupleKey{
User: subject,
Object: objectType.StringValue() + ":",
})
if err != nil {
return nil, err
}
tuples = append(tuples, typeTuples...)
}
return tuples, nil
}
func (provider *provider) getManagedRoleGrantTuples(orgID valuer.UUID, userID valuer.UUID) []*openfgav1.TupleKey {
tuples := []*openfgav1.TupleKey{}
@@ -375,21 +346,3 @@ func (provider *provider) getManagedRoleTransactionTuples(orgID valuer.UUID) []*
return tuples
}
func (provider *provider) readAllTuplesForRole(ctx context.Context, roleName string, orgID valuer.UUID) ([]*openfgav1.TupleKey, error) {
subject := authtypes.MustNewSubject(coretypes.NewResourceRole(), roleName, orgID, &coretypes.VerbAssignee)
tuples := make([]*openfgav1.TupleKey, 0)
for _, objectType := range provider.registry.Types() {
typeTuples, err := provider.ReadTuples(ctx, &openfgav1.ReadRequestTupleKey{
User: subject,
Object: objectType.StringValue() + ":",
})
if err != nil {
return nil, err
}
tuples = append(tuples, typeTuples...)
}
return tuples, nil
}

View File

@@ -105,10 +105,6 @@ func (server *Server) BatchCheck(ctx context.Context, tupleReq map[string]*openf
return server.pkgAuthzService.BatchCheck(ctx, tupleReq)
}
func (server *Server) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, objectType coretypes.Type) ([]*coretypes.Object, error) {
return server.pkgAuthzService.ListObjects(ctx, subject, relation, objectType)
}
func (server *Server) Write(ctx context.Context, additions []*openfgav1.TupleKey, deletions []*openfgav1.TupleKey) error {
return server.pkgAuthzService.Write(ctx, additions, deletions)
}

View File

@@ -2082,6 +2082,39 @@ export interface AuthtypesGettableAuthDomainDTO {
updatedAt?: string;
}
export interface AuthtypesGettableRoleDTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
description: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type string
*/
type: string;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
}
export interface AuthtypesGettableTokenDTO {
/**
* @type string
@@ -2325,39 +2358,6 @@ export interface AuthtypesPostableUserRoleDTO {
}
export interface AuthtypesRoleDTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
description: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type string
*/
type: string;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
}
export interface AuthtypesRoleWithTransactionGroupsDTO {
/**
* @type string
* @format date-time
@@ -10399,7 +10399,7 @@ export type ListRoles200 = {
/**
* @type array
*/
data: AuthtypesRoleDTO[];
data: AuthtypesGettableRoleDTO[];
/**
* @type string
*/
@@ -10421,7 +10421,7 @@ export type GetRolePathParameters = {
id: string;
};
export type GetRole200 = {
data: AuthtypesRoleWithTransactionGroupsDTO;
data: AuthtypesRoleDTO;
/**
* @type string
*/

View File

@@ -3,7 +3,7 @@ import { Select } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useListRoles } from 'api/generated/services/role';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import APIError from 'types/api/error';
import { popupContainer } from 'utils/selectPopupContainer';
@@ -16,7 +16,7 @@ export interface RoleOption {
}
export function useRoles(): {
roles: AuthtypesRoleDTO[];
roles: AuthtypesGettableRoleDTO[];
isLoading: boolean;
isError: boolean;
error: APIError | undefined;
@@ -33,7 +33,7 @@ export function useRoles(): {
}
export function getRoleOptions(
roles: AuthtypesRoleDTO[],
roles: AuthtypesGettableRoleDTO[],
valueField: 'id' | 'name',
): RoleOption[] {
return roles.map((role) => ({
@@ -79,7 +79,7 @@ interface BaseProps {
placeholder?: string;
className?: string;
getPopupContainer?: (trigger: HTMLElement) => HTMLElement;
roles?: AuthtypesRoleDTO[];
roles?: AuthtypesGettableRoleDTO[];
loading?: boolean;
isError?: boolean;
error?: APIError;

View File

@@ -4,7 +4,7 @@ import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { useCopyToClipboard } from 'react-use';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import RolesSelect from 'components/RolesSelect';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
@@ -24,7 +24,7 @@ interface OverviewTabProps {
onRolesChange: (v: string[]) => void;
isDisabled: boolean;
canUpdate?: boolean;
availableRoles: AuthtypesRoleDTO[];
availableRoles: AuthtypesGettableRoleDTO[];
rolesLoading?: boolean;
rolesError?: boolean;
rolesErrorObj?: APIError | undefined;

View File

@@ -3,7 +3,7 @@ import { useHistory } from 'react-router-dom';
import cx from 'classnames';
import { Pagination, Skeleton } from 'antd';
import { useListRoles } from 'api/generated/services/role';
import { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import PermissionDeniedFullPage from 'lib/authz/components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import ROUTES from 'constants/routes';
@@ -22,7 +22,7 @@ const PAGE_SIZE = 20;
type DisplayItem =
| { type: 'section'; label: string; count?: number }
| { type: 'role'; role: AuthtypesRoleDTO };
| { type: 'role'; role: AuthtypesGettableRoleDTO };
interface RolesListingTableProps {
searchQuery: string;
@@ -190,7 +190,7 @@ function RolesListingTable({
);
}
const renderRow = (role: AuthtypesRoleDTO): JSX.Element => (
const renderRow = (role: AuthtypesGettableRoleDTO): JSX.Element => (
<div
key={role.id}
className={cx(styles.tableRow, {

View File

@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from 'react-query';
import { ErrorType } from 'api/generatedAPIInstance';
import type {
AuthtypesPostableRoleDTO,
AuthtypesRoleWithTransactionGroupsDTO,
AuthtypesRoleDTO,
AuthtypesTransactionGroupDTO,
AuthtypesUpdatableRoleDTO,
} from 'api/generated/services/sigNoz.schemas';
@@ -133,7 +133,7 @@ export function transformTransactionGroupsToResourcePermissions(
}
export function transformApiToRolePermissions(
role: AuthtypesRoleWithTransactionGroupsDTO,
role: AuthtypesRoleDTO,
): RolePermissionsData {
return {
roleId: role.id,

View File

@@ -1,6 +1,6 @@
import { useCallback, useMemo } from 'react';
import { useQueryClient } from 'react-query';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
import {
getGetRolesByUserIDQueryKey,
useGetRolesByUserID,
@@ -21,11 +21,11 @@ export interface MemberRoleUpdateFailure {
}
interface UseMemberRoleManagerResult {
currentRoles: AuthtypesRoleDTO[];
currentRoles: AuthtypesGettableRoleDTO[];
isLoading: boolean;
applyDiff: (
localRoleIds: string[],
availableRoles: AuthtypesRoleDTO[],
availableRoles: AuthtypesGettableRoleDTO[],
) => Promise<MemberRoleUpdateFailure[]>;
}
@@ -40,7 +40,7 @@ export function useMemberRoleManager(
{ query: { enabled: !!userId && enabled } },
);
const currentRoles = useMemo<AuthtypesRoleDTO[]>(
const currentRoles = useMemo<AuthtypesGettableRoleDTO[]>(
() => data?.data ?? [],
[data?.data],
);
@@ -61,7 +61,7 @@ export function useMemberRoleManager(
const applyDiff = useCallback(
async (
localRoleIds: string[],
availableRoles: AuthtypesRoleDTO[],
availableRoles: AuthtypesGettableRoleDTO[],
): Promise<MemberRoleUpdateFailure[]> => {
const currentRoleIds = new Set(
currentRoles.map((r) => r.id).filter(Boolean),

View File

@@ -6,7 +6,7 @@ import {
useDeleteServiceAccountRoleDeprecated,
useGetServiceAccountRoles,
} from 'api/generated/services/serviceaccount';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
import { retryOn429 } from 'utils/errorUtils';
const enum PromiseStatus {
@@ -21,11 +21,11 @@ export interface RoleUpdateFailure {
}
interface UseServiceAccountRoleManagerResult {
currentRoles: AuthtypesRoleDTO[];
currentRoles: AuthtypesGettableRoleDTO[];
isLoading: boolean;
applyDiff: (
localRoleIds: string[],
availableRoles: AuthtypesRoleDTO[],
availableRoles: AuthtypesGettableRoleDTO[],
) => Promise<RoleUpdateFailure[]>;
}
@@ -40,7 +40,7 @@ export function useServiceAccountRoleManager(
{ query: { enabled: options?.enabled ?? true } },
);
const currentRoles = useMemo<AuthtypesRoleDTO[]>(
const currentRoles = useMemo<AuthtypesGettableRoleDTO[]>(
() => data?.data ?? [],
[data?.data],
);
@@ -64,7 +64,7 @@ export function useServiceAccountRoleManager(
const applyDiff = useCallback(
async (
localRoleIds: string[],
availableRoles: AuthtypesRoleDTO[],
availableRoles: AuthtypesGettableRoleDTO[],
): Promise<RoleUpdateFailure[]> => {
const currentRoleIds = new Set(
currentRoles.map((r) => r.id).filter(Boolean),

View File

@@ -1,8 +1,11 @@
import { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import {
AuthtypesGettableRoleDTO,
AuthtypesRoleDTO,
} from 'api/generated/services/sigNoz.schemas';
const orgId = '019ba2bb-2fa1-7b24-8159-cfca08617ef9';
export const managedRoles: AuthtypesRoleDTO[] = [
export const managedRoles: AuthtypesGettableRoleDTO[] = [
{
id: '019c24aa-2248-756f-9833-984f1ab63819',
createdAt: '2026-02-03T18:00:55.624356Z',
@@ -35,7 +38,7 @@ export const managedRoles: AuthtypesRoleDTO[] = [
},
];
export const customRoles: AuthtypesRoleDTO[] = [
export const customRoles: AuthtypesGettableRoleDTO[] = [
{
id: '019c24aa-3333-0001-aaaa-111111111111',
createdAt: '2026-02-10T10:30:00.000Z',
@@ -56,12 +59,24 @@ export const customRoles: AuthtypesRoleDTO[] = [
},
];
export const allRoles: AuthtypesRoleDTO[] = [...managedRoles, ...customRoles];
export const allRoles: AuthtypesGettableRoleDTO[] = [
...managedRoles,
...customRoles,
];
export const listRolesSuccessResponse = {
status: 'success',
data: allRoles,
};
export const customRoleResponse = { status: 'success', data: customRoles[0] };
export const managedRoleResponse = { status: 'success', data: managedRoles[0] };
const customRole: AuthtypesRoleDTO = {
...customRoles[0],
transactionGroups: [],
};
const managedRole: AuthtypesRoleDTO = {
...managedRoles[0],
transactionGroups: [],
};
export const customRoleResponse = { status: 'success', data: customRole };
export const managedRoleResponse = { status: 'success', data: managedRole };

View File

@@ -47,7 +47,7 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
Description: "This endpoint lists all roles",
Request: nil,
RequestContentType: "",
Response: make([]*authtypes.Role, 0),
Response: make([]*authtypes.GettableRole, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
@@ -73,7 +73,7 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
Description: "This endpoint gets a role",
Request: nil,
RequestContentType: "",
Response: new(authtypes.RoleWithTransactionGroups),
Response: new(authtypes.Role),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},

View File

@@ -30,17 +30,14 @@ type AuthZ interface {
// Write accepts the insertion tuples and the deletion tuples.
Write(context.Context, []*openfgav1.TupleKey, []*openfgav1.TupleKey) error
// Lists the selectors for objects assigned to subject (s) with relation (r) on resource (s)
ListObjects(context.Context, string, authtypes.Relation, coretypes.Type) ([]*coretypes.Object, error)
// ReadTuples reads tuples from the authorization server matching the given tuple key filter.
ReadTuples(context.Context, *openfgav1.ReadRequestTupleKey) ([]*openfgav1.TupleKey, error)
// Creates the role with its transaction groups.
Create(context.Context, valuer.UUID, *authtypes.RoleWithTransactionGroups) error
// Gets the role if it exists or creates one.
GetOrCreate(context.Context, valuer.UUID, *authtypes.Role) (*authtypes.Role, error)
Create(context.Context, valuer.UUID, *authtypes.Role) error
// Updates the role's metadata and reconciles its transaction groups.
Update(context.Context, valuer.UUID, *authtypes.RoleWithTransactionGroups) error
Update(context.Context, valuer.UUID, *authtypes.Role) error
// Deletes the role and tuples in authorization server.
Delete(context.Context, valuer.UUID, valuer.UUID) error
@@ -48,9 +45,6 @@ type AuthZ interface {
// Gets the role
Get(context.Context, valuer.UUID, valuer.UUID) (*authtypes.Role, error)
// Gets the role with transaction groups
GetWithTransactionGroups(context.Context, valuer.UUID, valuer.UUID) (*authtypes.RoleWithTransactionGroups, error)
// Gets the role by org_id and name
GetByOrgIDAndName(context.Context, valuer.UUID, string) (*authtypes.Role, error)
@@ -80,9 +74,6 @@ type AuthZ interface {
// Bootstrap managed roles transactions and user assignments
CreateManagedUserRoleTransactions(context.Context, valuer.UUID, valuer.UUID) error
// ReadTuples reads tuples from the authorization server matching the given tuple key filter.
ReadTuples(context.Context, *openfgav1.ReadRequestTupleKey) ([]*openfgav1.TupleKey, error)
}
// OnBeforeRoleDelete is a callback invoked before a role is deleted.

View File

@@ -83,10 +83,6 @@ func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.
return provider.store.Get(ctx, orgID, id)
}
func (provider *provider) GetWithTransactionGroups(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.RoleWithTransactionGroups, error) {
return nil, errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
}
func (provider *provider) GetByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*authtypes.Role, error) {
return provider.store.GetByOrgIDAndName(ctx, orgID, name)
}
@@ -181,15 +177,11 @@ func (provider *provider) CreateManagedUserRoleTransactions(ctx context.Context,
return provider.Grant(ctx, orgID, []string{authtypes.SigNozAdminRoleName}, authtypes.MustNewSubject(coretypes.NewResourceUser(), userID.String(), orgID, nil))
}
func (setter *provider) Create(_ context.Context, _ valuer.UUID, _ *authtypes.RoleWithTransactionGroups) error {
func (setter *provider) Create(_ context.Context, _ valuer.UUID, _ *authtypes.Role) error {
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
}
func (provider *provider) GetOrCreate(_ context.Context, _ valuer.UUID, _ *authtypes.Role) (*authtypes.Role, error) {
return nil, errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
}
func (provider *provider) Update(_ context.Context, _ valuer.UUID, _ *authtypes.RoleWithTransactionGroups) error {
func (provider *provider) Update(_ context.Context, _ valuer.UUID, _ *authtypes.Role) error {
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
}

View File

@@ -36,14 +36,14 @@ func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
return
}
roleWithTransactionGroups := authtypes.NewRoleWithTransactionGroups(req.Name, req.Description, authtypes.RoleTypeCustom, valuer.MustNewUUID(claims.OrgID), req.TransactionGroups)
err = handler.authz.Create(ctx, valuer.MustNewUUID(claims.OrgID), roleWithTransactionGroups)
role := authtypes.NewRole(req.Name, req.Description, authtypes.RoleTypeCustom, valuer.MustNewUUID(claims.OrgID), req.TransactionGroups)
err = handler.authz.Create(ctx, valuer.MustNewUUID(claims.OrgID), role)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, types.Identifiable{ID: roleWithTransactionGroups.ID})
render.Success(rw, http.StatusCreated, types.Identifiable{ID: role.ID})
}
func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
@@ -65,13 +65,13 @@ func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
return
}
roleWithTransactionGroups, err := handler.authz.GetWithTransactionGroups(ctx, valuer.MustNewUUID(claims.OrgID), roleID)
role, err := handler.authz.Get(ctx, valuer.MustNewUUID(claims.OrgID), roleID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, roleWithTransactionGroups)
render.Success(rw, http.StatusOK, role)
}
func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
@@ -88,7 +88,7 @@ func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
return
}
render.Success(rw, http.StatusOK, roles)
render.Success(rw, http.StatusOK, authtypes.NewGettableRolesFromRoles(roles))
}
func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
@@ -117,14 +117,13 @@ func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
return
}
roleWithTransactionGroups := authtypes.MakeRoleWithTransactionGroups(role, nil)
err = roleWithTransactionGroups.Update(req.Description, req.TransactionGroups)
err = role.Update(req.Description, req.TransactionGroups)
if err != nil {
render.Error(rw, err)
return
}
err = handler.authz.Update(ctx, valuer.MustNewUUID(claims.OrgID), roleWithTransactionGroups)
err = handler.authz.Update(ctx, valuer.MustNewUUID(claims.OrgID), role)
if err != nil {
render.Error(rw, err)
return

View File

@@ -218,6 +218,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewMigrateSSORoleMappingNamesFactory(sqlstore),
sqlmigration.NewAddMetricReductionRulesFactory(sqlstore, sqlschema),
sqlmigration.NewRemoveOrganizationTuplesFactory(sqlstore),
sqlmigration.NewAddRoleTransactionGroupsFactory(sqlstore, sqlschema),
)
}

View File

@@ -3,6 +3,7 @@ package sqlmigration
import (
"context"
"database/sql"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
@@ -18,6 +19,30 @@ type addManagedRoles struct {
sqlschema sqlschema.SQLSchema
}
type role struct {
bun.BaseModel `bun:"table:role"`
ID valuer.UUID `bun:"id,pk,type:text"`
CreatedAt time.Time `bun:"created_at"`
UpdatedAt time.Time `bun:"updated_at"`
Name string `bun:"name,type:string"`
Description string `bun:"description,type:string"`
Type string `bun:"type,type:string"`
OrgID valuer.UUID `bun:"org_id,type:string"`
}
func newManagedRole(name, description string, orgID valuer.UUID) *role {
return &role{
ID: valuer.GenerateUUID(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Name: name,
Description: description,
Type: authtypes.RoleTypeManaged.StringValue(),
OrgID: orgID,
}
}
func NewAddManagedRolesFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_managed_roles"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return newAddManagedRoles(ctx, ps, c, sqlstore, sqlschema)
@@ -54,28 +79,19 @@ func (migration *addManagedRoles) Up(ctx context.Context, db *bun.DB) error {
return err
}
managedRoles := []*authtypes.Role{}
managedRoles := []*role{}
for _, orgIDStr := range orgIDs {
orgID, err := valuer.NewUUID(orgIDStr)
if err != nil {
return err
}
// signoz admin
signozAdminRole := authtypes.NewRole(authtypes.SigNozAdminRoleName, authtypes.SigNozAdminRoleDescription, authtypes.RoleTypeManaged, orgID)
managedRoles = append(managedRoles, signozAdminRole)
// signoz editor
signozEditorRole := authtypes.NewRole(authtypes.SigNozEditorRoleName, authtypes.SigNozEditorRoleDescription, authtypes.RoleTypeManaged, orgID)
managedRoles = append(managedRoles, signozEditorRole)
// signoz viewer
signozViewerRole := authtypes.NewRole(authtypes.SigNozViewerRoleName, authtypes.SigNozViewerRoleDescription, authtypes.RoleTypeManaged, orgID)
managedRoles = append(managedRoles, signozViewerRole)
// signoz anonymous
signozAnonymousRole := authtypes.NewRole(authtypes.SigNozAnonymousRoleName, authtypes.SigNozAnonymousRoleDescription, authtypes.RoleTypeManaged, orgID)
managedRoles = append(managedRoles, signozAnonymousRole)
managedRoles = append(managedRoles,
newManagedRole(authtypes.SigNozAdminRoleName, authtypes.SigNozAdminRoleDescription, orgID),
newManagedRole(authtypes.SigNozEditorRoleName, authtypes.SigNozEditorRoleDescription, orgID),
newManagedRole(authtypes.SigNozViewerRoleName, authtypes.SigNozViewerRoleDescription, orgID),
newManagedRole(authtypes.SigNozAnonymousRoleName, authtypes.SigNozAnonymousRoleDescription, orgID),
)
}
if len(managedRoles) > 0 {

View File

@@ -0,0 +1,164 @@
package sqlmigration
import (
"context"
"database/sql"
"encoding/json"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/migrate"
)
type addRoleTransactionGroups struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
type roles struct {
bun.BaseModel `bun:"table:role"`
ID string `bun:"id,pk"`
Name string `bun:"name"`
OrgID string `bun:"org_id"`
}
func NewAddRoleTransactionGroupsFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("add_role_transaction_groups"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addRoleTransactionGroups{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
},
)
}
func (migration *addRoleTransactionGroups) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addRoleTransactionGroups) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
table, _, err := migration.sqlschema.GetTable(ctx, "role")
if err != nil {
return err
}
column := &sqlschema.Column{
Name: sqlschema.ColumnName("transaction_groups"),
DataType: sqlschema.DataTypeText,
Nullable: true,
}
sqls := migration.sqlschema.Operator().AddColumn(table, nil, column, nil)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
var storeID string
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
if err != nil {
return err
}
customRoles := make([]*roles, 0)
err = tx.NewSelect().
Model(&customRoles).
Column("id", "name", "org_id").
Where("type = ?", authtypes.RoleTypeCustom.StringValue()).
Where("transaction_groups IS NULL").
Scan(ctx)
if err != nil {
return err
}
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
for _, role := range customRoles {
roleSubject := "organization/" + role.OrgID + "/role/" + role.Name
var tupleRows *sql.Rows
if isPG {
tupleRows, err = tx.QueryContext(ctx, `
SELECT object_type, object_id, relation FROM tuple
WHERE store = ? AND _user = ?`,
storeID, "role:"+roleSubject+"#assignee",
)
} else {
tupleRows, err = tx.QueryContext(ctx, `
SELECT object_type, object_id, relation FROM tuple
WHERE store = ? AND user_object_type = ? AND user_object_id = ? AND user_relation = ?`,
storeID, "role", roleSubject, "assignee",
)
}
if err != nil {
return err
}
tuples := make([]*openfgav1.TupleKey, 0)
for tupleRows.Next() {
var objectType, objectID, relation string
if err := tupleRows.Scan(&objectType, &objectID, &relation); err != nil {
tupleRows.Close()
return err
}
tuples = append(tuples, &openfgav1.TupleKey{Object: objectType + ":" + objectID, Relation: relation})
}
if err := tupleRows.Err(); err != nil {
tupleRows.Close()
return err
}
tupleRows.Close()
groups := authtypes.MustNewTransactionGroupsFromTuples(tuples)
data, err := json.Marshal(groups)
if err != nil {
return err
}
if _, err := tx.NewUpdate().
Model(new(roles)).
Set("transaction_groups = ?", string(data)).
Where("id = ?", role.ID).
Exec(ctx); err != nil {
return err
}
}
for roleName, transactions := range coretypes.ManagedRoleToTransactions {
groups := authtypes.NewTransactionGroupsFromTransactions(transactions)
data, err := json.Marshal(groups)
if err != nil {
return err
}
if _, err := tx.NewUpdate().
Model(new(roles)).
Set("transaction_groups = ?", string(data)).
Where("type = ?", authtypes.RoleTypeManaged.StringValue()).
Where("name = ?", roleName).
Exec(ctx); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *addRoleTransactionGroups) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -66,15 +66,20 @@ type Role struct {
types.Identifiable
types.TimeAuditable
Name string `bun:"name,type:string" json:"name" required:"true"`
Description string `bun:"description,type:string" json:"description" required:"true"`
Type valuer.String `bun:"type,type:string" json:"type" required:"true"`
OrgID valuer.UUID `bun:"org_id,type:string" json:"orgId" required:"true"`
Name string `bun:"name,type:string" json:"name" required:"true"`
Description string `bun:"description,type:string" json:"description" required:"true"`
Type valuer.String `bun:"type,type:string" json:"type" required:"true"`
OrgID valuer.UUID `bun:"org_id,type:string" json:"orgId" required:"true"`
TransactionGroups TransactionGroups `bun:"transaction_groups,type:text" json:"transactionGroups" required:"true" nullable:"false"`
}
type RoleWithTransactionGroups struct {
*Role
TransactionGroups TransactionGroups `json:"transactionGroups" required:"true" nullable:"false"`
type GettableRole struct {
types.Identifiable
types.TimeAuditable
Name string `json:"name" required:"true"`
Description string `json:"description" required:"true"`
Type valuer.String `json:"type" required:"true"`
OrgID valuer.UUID `json:"orgId" required:"true"`
}
type PostableRole struct {
@@ -88,7 +93,7 @@ type UpdatableRole struct {
TransactionGroups TransactionGroups `json:"transactionGroups" required:"true" nullable:"false"`
}
func NewRole(name, description string, roleType valuer.String, orgID valuer.UUID) *Role {
func NewRole(name, description string, roleType valuer.String, orgID valuer.UUID, transactionGroups TransactionGroups) *Role {
return &Role{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
@@ -97,37 +102,37 @@ func NewRole(name, description string, roleType valuer.String, orgID valuer.UUID
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
Name: name,
Description: description,
Type: roleType,
OrgID: orgID,
}
}
func NewRoleWithTransactionGroups(name, description string, roleType valuer.String, orgID valuer.UUID, transactionGroups TransactionGroups) *RoleWithTransactionGroups {
role := NewRole(name, description, roleType, orgID)
return &RoleWithTransactionGroups{
Role: role,
Name: name,
Description: description,
Type: roleType,
OrgID: orgID,
TransactionGroups: transactionGroups,
}
}
func MakeRoleWithTransactionGroups(role *Role, transactionGroups TransactionGroups) *RoleWithTransactionGroups {
return &RoleWithTransactionGroups{
Role: role,
TransactionGroups: transactionGroups,
func NewGettableRolesFromRoles(roles []*Role) []*GettableRole {
gettableRoles := make([]*GettableRole, len(roles))
for index, role := range roles {
gettableRoles[index] = &GettableRole{
Identifiable: role.Identifiable,
TimeAuditable: role.TimeAuditable,
Name: role.Name,
Description: role.Description,
Type: role.Type,
OrgID: role.OrgID,
}
}
return gettableRoles
}
func NewManagedRoles(orgID valuer.UUID) []*Role {
return []*Role{
NewRole(SigNozAdminRoleName, SigNozAdminRoleDescription, RoleTypeManaged, orgID),
NewRole(SigNozEditorRoleName, SigNozEditorRoleDescription, RoleTypeManaged, orgID),
NewRole(SigNozViewerRoleName, SigNozViewerRoleDescription, RoleTypeManaged, orgID),
NewRole(SigNozAnonymousRoleName, SigNozAnonymousRoleDescription, RoleTypeManaged, orgID),
NewRole(SigNozAdminRoleName, SigNozAdminRoleDescription, RoleTypeManaged, orgID, NewTransactionGroupsFromTransactions(coretypes.ManagedRoleToTransactions[SigNozAdminRoleName])),
NewRole(SigNozEditorRoleName, SigNozEditorRoleDescription, RoleTypeManaged, orgID, NewTransactionGroupsFromTransactions(coretypes.ManagedRoleToTransactions[SigNozEditorRoleName])),
NewRole(SigNozViewerRoleName, SigNozViewerRoleDescription, RoleTypeManaged, orgID, NewTransactionGroupsFromTransactions(coretypes.ManagedRoleToTransactions[SigNozViewerRoleName])),
NewRole(SigNozAnonymousRoleName, SigNozAnonymousRoleDescription, RoleTypeManaged, orgID, NewTransactionGroupsFromTransactions(coretypes.ManagedRoleToTransactions[SigNozAnonymousRoleName])),
}
}
func NewStatsFromRoles(roles []*Role) map[string]any {
@@ -144,7 +149,7 @@ func NewStatsFromRoles(roles []*Role) map[string]any {
return stats
}
func (role *RoleWithTransactionGroups) Update(description string, transactionGroups TransactionGroups) error {
func (role *Role) Update(description string, transactionGroups TransactionGroups) error {
err := role.ErrIfManaged()
if err != nil {
return err

View File

@@ -1,6 +1,7 @@
package authtypes
import (
"database/sql/driver"
"encoding/json"
"github.com/SigNoz/signoz/pkg/errors"
@@ -70,6 +71,28 @@ func NewTransactionGroups(data []byte) (TransactionGroups, error) {
return groups, nil
}
func NewTransactionGroupsFromTransactions(transactions []coretypes.Transaction) TransactionGroups {
objectsByVerb := make(map[string][]*coretypes.Object)
for _, transaction := range transactions {
object := transaction.Object
objectsByVerb[transaction.Verb.StringValue()] = append(objectsByVerb[transaction.Verb.StringValue()], &object)
}
groups := make(TransactionGroups, 0)
for _, verb := range coretypes.Verbs {
objects := objectsByVerb[verb.StringValue()]
if len(objects) == 0 {
continue
}
for _, objectGroup := range coretypes.NewObjectGroupsFromObjects(objects) {
groups = append(groups, &TransactionGroup{Relation: Relation{Verb: verb}, ObjectGroup: *objectGroup})
}
}
return groups
}
func NewGettableTransaction(results []*TransactionWithAuthorization) []*GettableTransaction {
gettableTransactions := make([]*GettableTransaction, len(results))
for i, result := range results {
@@ -87,6 +110,48 @@ func (groups TransactionGroups) Diff(desired TransactionGroups) (additions, dele
return desired.subtract(groups), groups.subtract(desired)
}
func (groups TransactionGroups) MarshalJSON() ([]byte, error) {
if groups == nil {
return []byte("[]"), nil
}
return json.Marshal([]*TransactionGroup(groups))
}
func (groups TransactionGroups) Value() (driver.Value, error) {
data, err := json.Marshal(groups)
if err != nil {
return nil, err
}
return string(data), nil
}
func (groups *TransactionGroups) Scan(value any) error {
if value == nil {
*groups = nil
return nil
}
var data []byte
switch typed := value.(type) {
case string:
data = []byte(typed)
case []byte:
data = typed
default:
return errors.Newf(errors.TypeInternal, errors.CodeInternal, "unsupported type %T for transaction groups", value)
}
parsed, err := NewTransactionGroups(data)
if err != nil {
return err
}
*groups = parsed
return nil
}
func (transaction *Transaction) UnmarshalJSON(data []byte) error {
var shadow = struct {
Relation Relation

View File

@@ -68,7 +68,7 @@ func NewTuplesFromTransactionGroups(name string, orgID valuer.UUID, transactionG
return tuples, nil
}
func MustNewTransactionGroupsFromTuples(tuples []*openfgav1.TupleKey) []*TransactionGroup {
func MustNewTransactionGroupsFromTuples(tuples []*openfgav1.TupleKey) TransactionGroups {
objectsByRelation := make(map[string][]*coretypes.Object)
for _, tuple := range tuples {
@@ -81,7 +81,7 @@ func MustNewTransactionGroupsFromTuples(tuples []*openfgav1.TupleKey) []*Transac
objectsByRelation[verb.StringValue()] = append(objectsByRelation[verb.StringValue()], object)
}
transactionGroups := make([]*TransactionGroup, 0)
transactionGroups := make(TransactionGroups, 0)
for _, verb := range coretypes.Verbs {
objects := objectsByRelation[verb.StringValue()]
if len(objects) == 0 {