Skip to content
Listing Subjects

Listing Subjects

ListSubjects returns all subjects of a given type that have a specific relation on an object.

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

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
}

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
}

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
}

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
}

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
}

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
}

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
}

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)
}

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()

See Also