Listing Subjects

Listing Subjects

The ListSubjects operation returns all subjects of a given type that have a specific relation on an object. This answers the question: “Who has access to this resource?”

Basic Usage

// Find all users who can read a repository (with pagination)
userIDs, cursor, err := checker.ListSubjects(ctx,
    authz.Repository("456"),
    authz.RelCanRead,
    "user",
    melange.PageOptions{Limit: 100},
)
if err != nil {
    return err
}

// userIDs = ["alice", "bob", "carol"]
// cursor = nil when no more pages, or a string to fetch the next page
// Find all users who can read a repository (with pagination)
const { rows } = await pool.query(
  'SELECT subject_id, next_cursor FROM list_accessible_subjects($1, $2, $3, $4, $5, $6)',
  ['repository', '456', 'can_read', 'user', 100, null]
);
const userIds = rows.map(row => row.subject_id);
const nextCursor = rows.length > 0 ? rows[0].next_cursor : null;

// userIds = ["alice", "bob", "carol"]
-- Get all users who can view document 456 (first 100)
SELECT subject_id, next_cursor
FROM list_accessible_subjects('document', '456', 'viewer', 'user', 100, NULL);

-- Returns a table with subject_id and next_cursor columns
-- next_cursor is NULL when no more pages exist

Parameters

ParameterTypeDescription
object_typetextType of the object (e.g., 'repository')
object_idtextID of the object
relationtextThe relation to check
subject_typetextType of subjects to return
p_limitintMaximum number of results per page (NULL = no limit)
p_aftertextCursor from previous page (NULL = start from beginning)

Return Value

Returns a table with subject_id and next_cursor columns. The next_cursor value is repeated on every row for convenience - use the last row’s cursor to fetch the next page. Returns an empty result set if no subjects found (not an error).

Ordering: Results are ordered with wildcard subjects ('*') first, then alphabetically by subject_id. This ensures stable pagination while keeping wildcard entries grouped at the top.

Pagination

Cursor-Based Pagination

Use cursor-based pagination to iterate through large result sets efficiently:

// Paginate through all users with access
var cursor *string
for {
    ids, next, err := checker.ListSubjects(ctx,
        authz.Repository("456"),
        authz.RelCanRead,
        "user",
        melange.PageOptions{Limit: 100, After: cursor},
    )
    if err != nil {
        return err
    }

    for _, id := range ids {
        // Process each user ID
        fmt.Println("Has access:", id)
    }

    if next == nil {
        break // No more pages
    }
    cursor = next
}
// Paginate through all users with access
let cursor: string | null = null;

while (true) {
  const { rows } = await pool.query(
    'SELECT subject_id, next_cursor FROM list_accessible_subjects($1, $2, $3, $4, $5, $6)',
    ['repository', repoId, 'can_read', 'user', 100, cursor]
  );

  for (const row of rows) {
    // Process each user ID
    console.log('Has access:', row.subject_id);
  }

  cursor = rows.length > 0 ? rows[rows.length - 1].next_cursor : null;
  if (!cursor) break; // No more pages
}
-- First page
SELECT subject_id, next_cursor
FROM list_accessible_subjects('document', '456', 'viewer', 'user', 100, NULL);

-- Returns: subject_id | next_cursor
--          *          | user-100   (wildcard first)
--          alice      | user-100
--          bob        | user-100
--          ...

-- Next page (use the next_cursor value)
SELECT subject_id, next_cursor
FROM list_accessible_subjects('document', '456', 'viewer', 'user', 100, 'user-100');

Fetching All Results

For convenience, use the ListSubjectsAll helper which automatically paginates through all results:

// Get all users who can read (auto-paginates internally)
allUserIDs, err := checker.ListSubjectsAll(ctx,
    authz.Repository("456"),
    authz.RelCanRead,
    "user",
)
if err != nil {
    return err
}
// allUserIDs contains all user IDs with access
Use with caution: ListSubjectsAll loads all IDs into memory. For large datasets, prefer paginated queries with ListSubjects to control memory usage.

Examples

Show Access List

Display who has access to a resource:

func GetAccessList(ctx context.Context, repo Repository) ([]AccessEntry, error) {
    var entries []AccessEntry

    // Get users with each permission level
    permissions := []string{"owner", "admin", "can_write", "can_read"}

    for _, perm := range permissions {
        userIDs, err := checker.ListSubjectsAll(ctx, repo, perm, "user")
        if err != nil {
            return nil, err
        }

        for _, id := range userIDs {
            entries = append(entries, AccessEntry{
                UserID:     id,
                Permission: perm,
            })
        }
    }

    return entries, nil
}
interface AccessEntry {
  userId: string;
  permission: string;
}

async function getAccessList(repoId: string): Promise<AccessEntry[]> {
  const permissions = ['owner', 'admin', 'can_write', 'can_read'];
  const entries: AccessEntry[] = [];

  for (const perm of permissions) {
    const { rows } = await pool.query(
      'SELECT subject_id FROM list_accessible_subjects($1, $2, $3, $4, $5, $6)',
      ['repository', repoId, perm, 'user', null, null]
    );

    for (const row of rows) {
      entries.push({
        userId: row.subject_id,
        permission: perm,
      });
    }
  }

  return entries;
}
-- Join with users table to get full user records (NULL limit returns all)
SELECT u.*
FROM users u
JOIN list_accessible_subjects('document', '456', 'viewer', 'user', NULL, NULL) a
    ON u.id::text = a.subject_id;

Check for Any Access

Verify at least one user has access:

func HasAnyViewers(ctx context.Context, doc Document) (bool, error) {
    // Use Limit: 1 to efficiently check for any results
    viewers, _, err := checker.ListSubjects(ctx, doc, "can_read", "user",
        melange.PageOptions{Limit: 1})
    if err != nil {
        return false, err
    }
    return len(viewers) > 0, nil
}
async function hasAnyViewers(docId: string): Promise<boolean> {
  // Use limit 1 to efficiently check for any results
  const { rows } = await pool.query(
    'SELECT subject_id FROM list_accessible_subjects($1, $2, $3, $4, $5, $6) LIMIT 1',
    ['document', docId, 'can_read', 'user', 1, null]
  );
  return rows.length > 0;
}
-- Check if anyone has access (returns true/false)
SELECT EXISTS(
    SELECT 1 FROM list_accessible_subjects('document', '456', 'viewer', 'user', 1, NULL)
);

Notify All Users with Access

func NotifyCollaborators(ctx context.Context, repo Repository, message string) error {
    // Get all users who can read (using ListSubjectsAll)
    userIDs, err := checker.ListSubjectsAll(ctx, repo, "can_read", "user")
    if err != nil {
        return err
    }

    for _, userID := range userIDs {
        if err := notificationService.Send(ctx, userID, message); err != nil {
            log.Printf("Failed to notify user %s: %v", userID, err)
        }
    }

    return nil
}
async function notifyCollaborators(repoId: string, message: string): Promise<void> {
  // Get all users who can read (NULL limit returns all)
  const { rows } = await pool.query(
    'SELECT subject_id FROM list_accessible_subjects($1, $2, $3, $4, $5, $6)',
    ['repository', repoId, 'can_read', 'user', null, null]
  );

  for (const row of rows) {
    try {
      await notificationService.send(row.subject_id, message);
    } catch (err) {
      console.error(`Failed to notify user ${row.subject_id}:`, err);
    }
  }
}

Audit Access

type AccessAuditEntry struct {
    ObjectType string
    ObjectID   string
    UserID     string
    Permission string
    Timestamp  time.Time
}

func AuditRepositoryAccess(ctx context.Context, repo Repository) ([]AccessAuditEntry, error) {
    var audit []AccessAuditEntry

    permissions := []string{"owner", "admin", "can_write", "can_read"}

    for _, perm := range permissions {
        userIDs, err := checker.ListSubjectsAll(ctx, repo, perm, "user")
        if err != nil {
            return nil, err
        }

        for _, userID := range userIDs {
            audit = append(audit, AccessAuditEntry{
                ObjectType: "repository",
                ObjectID:   fmt.Sprint(repo.ID),
                UserID:     userID,
                Permission: perm,
                Timestamp:  time.Now(),
            })
        }
    }

    return audit, nil
}
interface AccessAuditEntry {
  objectType: string;
  objectId: string;
  userId: string;
  permission: string;
  timestamp: Date;
}

async function auditRepositoryAccess(repoId: string): Promise<AccessAuditEntry[]> {
  const permissions = ['owner', 'admin', 'can_write', 'can_read'];
  const audit: AccessAuditEntry[] = [];

  for (const perm of permissions) {
    const { rows } = await pool.query(
      'SELECT subject_id FROM list_accessible_subjects($1, $2, $3, $4, $5, $6)',
      ['repository', repoId, perm, 'user', null, null]
    );

    for (const row of rows) {
      audit.push({
        objectType: 'repository',
        objectId: repoId,
        userId: row.subject_id,
        permission: perm,
        timestamp: new Date(),
      });
    }
  }

  return audit;
}

Performance Characteristics

ListSubjects uses a recursive CTE similar to ListObjects:

Tuple CountLatency
1K tuples~708us
10K tuples~6.3ms
100K tuples~42ms
1M tuples~864ms

ListSubjects is typically faster than ListObjects because it starts from a specific object rather than searching across all objects.

Decision Override Behavior

DecisionBehavior
DecisionUnsetNormal database query
DecisionDenyReturns empty slice (no subjects have access)
DecisionAllowFalls through to database query

Note: DecisionAllow cannot enumerate “all” subjects, so it performs the normal query.

Caching

Like ListObjects, ListSubjects does not use the permission cache. Implement application-level caching if needed:

func (c *CachedChecker) ListSubjectsAll(ctx context.Context, object ObjectLike, relation RelationLike, subjectType ObjectType) ([]string, error) {
    key := fmt.Sprintf("subjects:%s:%s:%s:%s",
        object.FGAObject().Type,
        object.FGAObject().ID,
        relation.FGARelation(),
        subjectType,
    )

    if cached, ok := c.cache.Get(key); ok {
        return cached.([]string), nil
    }

    ids, err := c.Checker.ListSubjectsAll(ctx, object, relation, subjectType)
    if err != nil {
        return nil, err
    }

    c.cache.Add(key, ids)
    return ids, nil
}
import { LRUCache } from 'lru-cache';

const subjectCache = new LRUCache<string, string[]>({
  max: 1000,
  ttl: 60 * 1000, // 1 minute
});

async function listSubjectsCached(
  objectType: string,
  objectId: string,
  relation: string,
  subjectType: string
): Promise<string[]> {
  const key = `subjects:${objectType}:${objectId}:${relation}:${subjectType}`;

  const cached = subjectCache.get(key);
  if (cached) {
    return cached;
  }

  const { rows } = await pool.query(
    'SELECT subject_id FROM list_accessible_subjects($1, $2, $3, $4, $5, $6)',
    [objectType, objectId, relation, subjectType, null, null]
  );
  const ids = rows.map(r => r.subject_id);

  subjectCache.set(key, ids);
  return ids;
}

Common Patterns

Permission Comparison

Compare who has what level of access:

type PermissionBreakdown struct {
    Owners  []string
    Admins  []string
    Writers []string
    Readers []string
}

func GetPermissionBreakdown(ctx context.Context, repo Repository) (*PermissionBreakdown, error) {
    var (
        owners, admins, writers, readers []string
        ownerErr, adminErr, writerErr, readerErr error
    )

    var wg sync.WaitGroup
    wg.Add(4)

    go func() {
        defer wg.Done()
        owners, ownerErr = checker.ListSubjectsAll(ctx, repo, "owner", "user")
    }()
    go func() {
        defer wg.Done()
        admins, adminErr = checker.ListSubjectsAll(ctx, repo, "admin", "user")
    }()
    go func() {
        defer wg.Done()
        writers, writerErr = checker.ListSubjectsAll(ctx, repo, "can_write", "user")
    }()
    go func() {
        defer wg.Done()
        readers, readerErr = checker.ListSubjectsAll(ctx, repo, "can_read", "user")
    }()

    wg.Wait()

    // Check errors...

    return &PermissionBreakdown{
        Owners:  owners,
        Admins:  admins,
        Writers: writers,
        Readers: readers,
    }, nil
}
interface PermissionBreakdown {
  owners: string[];
  admins: string[];
  writers: string[];
  readers: string[];
}

async function getPermissionBreakdown(repoId: string): Promise<PermissionBreakdown> {
  // Fetch all permission sets in parallel (NULL limit returns all)
  const [ownersRes, adminsRes, writersRes, readersRes] = await Promise.all([
    pool.query('SELECT subject_id FROM list_accessible_subjects($1, $2, $3, $4, $5, $6)', ['repository', repoId, 'owner', 'user', null, null]),
    pool.query('SELECT subject_id FROM list_accessible_subjects($1, $2, $3, $4, $5, $6)', ['repository', repoId, 'admin', 'user', null, null]),
    pool.query('SELECT subject_id FROM list_accessible_subjects($1, $2, $3, $4, $5, $6)', ['repository', repoId, 'can_write', 'user', null, null]),
    pool.query('SELECT subject_id FROM list_accessible_subjects($1, $2, $3, $4, $5, $6)', ['repository', repoId, 'can_read', 'user', null, null]),
  ]);

  return {
    owners: ownersRes.rows.map(r => r.subject_id),
    admins: adminsRes.rows.map(r => r.subject_id),
    writers: writersRes.rows.map(r => r.subject_id),
    readers: readersRes.rows.map(r => r.subject_id),
  };
}

Team Members via Object

When teams are modeled as objects:

// Schema: type team
//           relations
//             define member: [user]

func GetTeamMembers(ctx context.Context, teamID string) ([]User, error) {
    team := melange.Object{Type: "team", ID: teamID}

    memberIDs, err := checker.ListSubjectsAll(ctx, team, "member", "user")
    if err != nil {
        return nil, err
    }

    return db.GetUsersByIDs(ctx, memberIDs)
}
// Schema: type team
//           relations
//             define member: [user]

async function getTeamMembers(teamId: string): Promise<User[]> {
  const { rows } = await pool.query(
    'SELECT subject_id FROM list_accessible_subjects($1, $2, $3, $4, $5, $6)',
    ['team', teamId, 'member', 'user', null, null]
  );
  const memberIds = rows.map(r => r.subject_id);

  return db.getUsersByIds(memberIds);
}
-- Get all team members with access (userset filter)
-- Note: Use 'team#member' as subject_type to filter by userset
SELECT subject_id, next_cursor
FROM list_accessible_subjects('document', '456', 'viewer', 'team#member', NULL, NULL);

Transaction Consistency

Paginated queries across multiple calls can observe changes between pages. For consistency-critical flows, run paging inside a transaction with repeatable-read or snapshot semantics:

// For consistent pagination across pages, use a transaction
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead})
if err != nil {
    return err
}
defer tx.Rollback()

txChecker := melange.NewChecker(tx)

var allIDs []string
var cursor *string
for {
    ids, next, err := txChecker.ListSubjects(ctx, repo, "can_read", "user",
        melange.PageOptions{Limit: 100, After: cursor})
    if err != nil {
        return err
    }
    allIDs = append(allIDs, ids...)
    if next == nil {
        break
    }
    cursor = next
}

tx.Commit()
-- For consistent pagination, use a transaction
BEGIN ISOLATION LEVEL REPEATABLE READ;

-- First page
SELECT subject_id, next_cursor
FROM list_accessible_subjects('repository', '456', 'can_read', 'user', 100, NULL);

-- Subsequent pages within the same transaction see consistent data
SELECT subject_id, next_cursor
FROM list_accessible_subjects('repository', '456', 'can_read', 'user', 100, 'user-100');

COMMIT;

See Also