Files
pad/internal/store/grants.go
T
xarmian 9ad718178d fix(store): workspace-scope the item-grant lookup (TASK-2403)
ResolveUserPermission matched item grants on item_id alone, so a grant on
an item in workspace B resolved for a request scoped to workspace A. This
is the underlying lookup behind the delete escalation PLAN-2382 fixed at
the handler; closing it here means the next caller does not have to
remember the workspace-identity guard.

The adjacent collection-grant lookup had the identical defect and the
identical safety argument, so it is scoped in the same commit rather than
leaving a second unscoped lookup three lines below the one DR-5 names.

Safe for every caller: all three (requireEditPermission, the collab
access check, crossWorkspaceEditAllowed) already pass the workspace the
item/collection was resolved in, and grant rows carry the workspace they
were minted in — the same scoping listUserItemGrants already uses.

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
2026-08-01 23:59:38 +00:00

555 lines
18 KiB
Go

package store
import (
"database/sql"
"fmt"
"github.com/PerpetualSoftware/pad/internal/models"
)
// --- Collection Grants ---
// CreateCollectionGrant creates a direct grant on a collection for a user.
func (s *Store) CreateCollectionGrant(workspaceID, collectionID, userID, permission, grantedBy string) (*models.CollectionGrant, error) {
id := newID()
ts := now()
_, err := s.db.Exec(s.q(`
INSERT INTO collection_grants (id, collection_id, workspace_id, user_id, permission, granted_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`), id, collectionID, workspaceID, userID, permission, grantedBy, ts)
if err != nil {
return nil, fmt.Errorf("create collection grant: %w", err)
}
return s.GetCollectionGrant(id)
}
// GetCollectionGrant retrieves a collection grant by ID.
func (s *Store) GetCollectionGrant(id string) (*models.CollectionGrant, error) {
var g models.CollectionGrant
var createdAt string
err := s.db.QueryRow(s.q(`
SELECT cg.id, cg.collection_id, cg.workspace_id, cg.user_id, cg.permission, cg.granted_by, cg.created_at,
COALESCE(u.name, ''), COALESCE(u.email, ''), COALESCE(u.username, '')
FROM collection_grants cg
LEFT JOIN users u ON u.id = cg.user_id
WHERE cg.id = ?
`), id).Scan(
&g.ID, &g.CollectionID, &g.WorkspaceID, &g.UserID, &g.Permission, &g.GrantedBy, &createdAt,
&g.UserName, &g.UserEmail, &g.UserUsername,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get collection grant: %w", err)
}
g.CreatedAt = parseTime(createdAt)
return &g, nil
}
// ListCollectionGrants returns all grants on a collection.
func (s *Store) ListCollectionGrants(collectionID string) ([]models.CollectionGrant, error) {
rows, err := s.db.Query(s.q(`
SELECT cg.id, cg.collection_id, cg.workspace_id, cg.user_id, cg.permission, cg.granted_by, cg.created_at,
COALESCE(u.name, ''), COALESCE(u.email, ''), COALESCE(u.username, '')
FROM collection_grants cg
LEFT JOIN users u ON u.id = cg.user_id
WHERE cg.collection_id = ?
ORDER BY cg.created_at ASC
`), collectionID)
if err != nil {
return nil, fmt.Errorf("list collection grants: %w", err)
}
defer rows.Close()
var result []models.CollectionGrant
for rows.Next() {
var g models.CollectionGrant
var createdAt string
if err := rows.Scan(
&g.ID, &g.CollectionID, &g.WorkspaceID, &g.UserID, &g.Permission, &g.GrantedBy, &createdAt,
&g.UserName, &g.UserEmail, &g.UserUsername,
); err != nil {
return nil, err
}
g.CreatedAt = parseTime(createdAt)
result = append(result, g)
}
return result, rows.Err()
}
// DeleteCollectionGrant revokes a collection grant by ID, scoped to a workspace.
func (s *Store) DeleteCollectionGrant(id, workspaceID string) error {
result, err := s.db.Exec(s.q("DELETE FROM collection_grants WHERE id = ? AND workspace_id = ?"), id, workspaceID)
if err != nil {
return fmt.Errorf("delete collection grant: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}
// --- Item Grants ---
// CreateItemGrant creates a direct grant on an item for a user.
func (s *Store) CreateItemGrant(workspaceID, itemID, userID, permission, grantedBy string) (*models.ItemGrant, error) {
id := newID()
ts := now()
_, err := s.db.Exec(s.q(`
INSERT INTO item_grants (id, item_id, workspace_id, user_id, permission, granted_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`), id, itemID, workspaceID, userID, permission, grantedBy, ts)
if err != nil {
return nil, fmt.Errorf("create item grant: %w", err)
}
return s.GetItemGrant(id)
}
// GetItemGrant retrieves an item grant by ID.
func (s *Store) GetItemGrant(id string) (*models.ItemGrant, error) {
var g models.ItemGrant
var createdAt string
err := s.db.QueryRow(s.q(`
SELECT ig.id, ig.item_id, ig.workspace_id, ig.user_id, ig.permission, ig.granted_by, ig.created_at,
COALESCE(u.name, ''), COALESCE(u.email, ''), COALESCE(u.username, '')
FROM item_grants ig
LEFT JOIN users u ON u.id = ig.user_id
WHERE ig.id = ?
`), id).Scan(
&g.ID, &g.ItemID, &g.WorkspaceID, &g.UserID, &g.Permission, &g.GrantedBy, &createdAt,
&g.UserName, &g.UserEmail, &g.UserUsername,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get item grant: %w", err)
}
g.CreatedAt = parseTime(createdAt)
return &g, nil
}
// ListItemGrants returns all grants on an item.
func (s *Store) ListItemGrants(itemID string) ([]models.ItemGrant, error) {
rows, err := s.db.Query(s.q(`
SELECT ig.id, ig.item_id, ig.workspace_id, ig.user_id, ig.permission, ig.granted_by, ig.created_at,
COALESCE(u.name, ''), COALESCE(u.email, ''), COALESCE(u.username, '')
FROM item_grants ig
LEFT JOIN users u ON u.id = ig.user_id
WHERE ig.item_id = ?
ORDER BY ig.created_at ASC
`), itemID)
if err != nil {
return nil, fmt.Errorf("list item grants: %w", err)
}
defer rows.Close()
var result []models.ItemGrant
for rows.Next() {
var g models.ItemGrant
var createdAt string
if err := rows.Scan(
&g.ID, &g.ItemID, &g.WorkspaceID, &g.UserID, &g.Permission, &g.GrantedBy, &createdAt,
&g.UserName, &g.UserEmail, &g.UserUsername,
); err != nil {
return nil, err
}
g.CreatedAt = parseTime(createdAt)
result = append(result, g)
}
return result, rows.Err()
}
// DeleteItemGrant revokes an item grant by ID, scoped to a workspace.
func (s *Store) DeleteItemGrant(id, workspaceID string) error {
result, err := s.db.Exec(s.q("DELETE FROM item_grants WHERE id = ? AND workspace_id = ?"), id, workspaceID)
if err != nil {
return fmt.Errorf("delete item grant: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}
// --- Cross-cutting queries ---
// ListUserGrants returns all collection and item grants for a user in a workspace.
func (s *Store) ListUserGrants(workspaceID, userID string) ([]models.CollectionGrant, []models.ItemGrant, error) {
collGrants, err := s.listUserCollectionGrants(workspaceID, userID)
if err != nil {
return nil, nil, err
}
itemGrants, err := s.listUserItemGrants(workspaceID, userID)
if err != nil {
return nil, nil, err
}
return collGrants, itemGrants, nil
}
func (s *Store) listUserCollectionGrants(workspaceID, userID string) ([]models.CollectionGrant, error) {
rows, err := s.db.Query(s.q(`
SELECT cg.id, cg.collection_id, cg.workspace_id, cg.user_id, cg.permission, cg.granted_by, cg.created_at,
COALESCE(u.name, ''), COALESCE(u.email, ''), COALESCE(u.username, '')
FROM collection_grants cg
LEFT JOIN users u ON u.id = cg.user_id
WHERE cg.workspace_id = ? AND cg.user_id = ?
ORDER BY cg.created_at ASC
`), workspaceID, userID)
if err != nil {
return nil, fmt.Errorf("list user collection grants: %w", err)
}
defer rows.Close()
var result []models.CollectionGrant
for rows.Next() {
var g models.CollectionGrant
var createdAt string
if err := rows.Scan(
&g.ID, &g.CollectionID, &g.WorkspaceID, &g.UserID, &g.Permission, &g.GrantedBy, &createdAt,
&g.UserName, &g.UserEmail, &g.UserUsername,
); err != nil {
return nil, err
}
g.CreatedAt = parseTime(createdAt)
result = append(result, g)
}
return result, rows.Err()
}
func (s *Store) listUserItemGrants(workspaceID, userID string) ([]models.ItemGrant, error) {
rows, err := s.db.Query(s.q(`
SELECT ig.id, ig.item_id, ig.workspace_id, ig.user_id, ig.permission, ig.granted_by, ig.created_at,
COALESCE(u.name, ''), COALESCE(u.email, ''), COALESCE(u.username, '')
FROM item_grants ig
LEFT JOIN users u ON u.id = ig.user_id
WHERE ig.workspace_id = ? AND ig.user_id = ?
ORDER BY ig.created_at ASC
`), workspaceID, userID)
if err != nil {
return nil, fmt.Errorf("list user item grants: %w", err)
}
defer rows.Close()
var result []models.ItemGrant
for rows.Next() {
var g models.ItemGrant
var createdAt string
if err := rows.Scan(
&g.ID, &g.ItemID, &g.WorkspaceID, &g.UserID, &g.Permission, &g.GrantedBy, &createdAt,
&g.UserName, &g.UserEmail, &g.UserUsername,
); err != nil {
return nil, err
}
g.CreatedAt = parseTime(createdAt)
result = append(result, g)
}
return result, rows.Err()
}
// RevokeAllUserGrants deletes all collection and item grants for a user in a workspace.
// Used when removing a member with "revoke all access" option.
func (s *Store) RevokeAllUserGrants(workspaceID, userID string) error {
_, err := s.db.Exec(s.q("DELETE FROM collection_grants WHERE workspace_id = ? AND user_id = ?"), workspaceID, userID)
if err != nil {
return fmt.Errorf("revoke collection grants: %w", err)
}
_, err = s.db.Exec(s.q("DELETE FROM item_grants WHERE workspace_id = ? AND user_id = ?"), workspaceID, userID)
if err != nil {
return fmt.Errorf("revoke item grants: %w", err)
}
return nil
}
// ResolveUserPermission resolves the effective permission for a user on a specific
// item, following the permission resolution order from DOC-406:
// 1. Owner bypass → 2. Item grant → 3. Collection grant → 4. Membership → 5. Deny
// Returns the permission string ("view", "edit", "admin", "owner") or "" if denied.
func (s *Store) ResolveUserPermission(workspaceID, userID, itemID, collectionID string) (string, error) {
// 1. Is user the workspace owner?
var ownerID string
err := s.db.QueryRow(s.q("SELECT owner_id FROM workspaces WHERE id = ?"), workspaceID).Scan(&ownerID)
if err != nil {
return "", fmt.Errorf("check workspace owner: %w", err)
}
if ownerID == userID {
return "owner", nil
}
// 2. Item-level grant?
//
// Scoped by workspace_id (PLAN-2391 DR-5). The grant rows already carry
// the workspace they were minted in (CreateItemGrant, and the same
// scoping listUserItemGrants uses), and every caller passes the
// workspace the item was resolved in — so this narrows nothing
// legitimate. What it closes is a caller handing us an itemID from a
// FOREIGN workspace alongside the request's own workspaceID: without
// the predicate, that foreign item's grant resolved and answered for
// the wrong workspace. That is the shape of the delete escalation
// PLAN-2382 fixed at the handler (internal/server/handlers_storage.go);
// this closes it at the lookup so the next caller need not remember.
if itemID != "" {
var perm string
err := s.db.QueryRow(s.q(
"SELECT permission FROM item_grants WHERE workspace_id = ? AND item_id = ? AND user_id = ?"),
workspaceID, itemID, userID).Scan(&perm)
if err == nil {
return perm, nil
}
if err != sql.ErrNoRows {
return "", fmt.Errorf("check item grant: %w", err)
}
}
// 3. Collection-level grant?
//
// Workspace-scoped for the same reason as the item grant above — the
// defect and its safety argument are identical, so the two are fixed
// together rather than leaving a second unscoped lookup three lines
// below the one DR-5 names.
if collectionID != "" {
var perm string
err := s.db.QueryRow(s.q(
"SELECT permission FROM collection_grants WHERE workspace_id = ? AND collection_id = ? AND user_id = ?"),
workspaceID, collectionID, userID).Scan(&perm)
if err == nil {
return perm, nil
}
if err != sql.ErrNoRows {
return "", fmt.Errorf("check collection grant: %w", err)
}
}
// 4. Workspace membership?
member, err := s.GetWorkspaceMember(workspaceID, userID)
if err != nil {
return "", fmt.Errorf("check membership: %w", err)
}
if member != nil {
// Check collection visibility for members with "specific" access
if collectionID != "" && member.CollectionAccess == "specific" {
visIDs, err := s.VisibleCollectionIDs(workspaceID, userID)
if err != nil {
return "", err
}
visible := false
for _, id := range visIDs {
if id == collectionID {
visible = true
break
}
}
if !visible {
return "", nil // Collection not visible → deny
}
}
return member.Role, nil // "owner", "editor", or "viewer"
}
// 5. Deny
return "", nil
}
// UserHasGrantsInWorkspace checks if a user has any active collection or item
// grants in a workspace (even though they are not a member). Used to detect guests.
// Grants on soft-deleted items or soft-deleted collections are excluded so
// archived resources don't create phantom guest access.
func (s *Store) UserHasGrantsInWorkspace(workspaceID, userID string) (bool, error) {
var count int
err := s.db.QueryRow(s.q(`
SELECT COUNT(*) FROM (
SELECT 1 FROM collection_grants cg
JOIN collections c ON c.id = cg.collection_id
WHERE cg.workspace_id = ? AND cg.user_id = ? AND c.deleted_at IS NULL
UNION ALL
SELECT 1 FROM item_grants ig
JOIN items i ON i.id = ig.item_id
JOIN collections c ON c.id = i.collection_id
WHERE ig.workspace_id = ? AND ig.user_id = ? AND i.deleted_at IS NULL AND c.deleted_at IS NULL
LIMIT 1
) AS grant_check
`), workspaceID, userID, workspaceID, userID).Scan(&count)
if err != nil {
return false, fmt.Errorf("check user grants: %w", err)
}
return count > 0, nil
}
// GuestVisibleCollectionIDs returns the collection IDs a guest (non-member with
// grants) can see. Includes collections with direct collection_grants and
// collections that contain items the user has item_grants on.
func (s *Store) GuestVisibleCollectionIDs(workspaceID, userID string) ([]string, error) {
ids := make(map[string]bool)
// Collections with direct grants (excluding soft-deleted collections)
rows, err := s.db.Query(s.q(`
SELECT DISTINCT cg.collection_id FROM collection_grants cg
JOIN collections c ON c.id = cg.collection_id
WHERE cg.workspace_id = ? AND cg.user_id = ? AND c.deleted_at IS NULL
`), workspaceID, userID)
if err != nil {
return nil, fmt.Errorf("guest collection grants: %w", err)
}
defer rows.Close()
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids[id] = true
}
// Collections containing items with direct grants (excluding soft-deleted)
itemRows, err := s.db.Query(s.q(`
SELECT DISTINCT i.collection_id
FROM item_grants ig
JOIN items i ON i.id = ig.item_id
JOIN collections c ON c.id = i.collection_id
WHERE ig.workspace_id = ? AND ig.user_id = ? AND i.deleted_at IS NULL AND c.deleted_at IS NULL
`), workspaceID, userID)
if err != nil {
return nil, fmt.Errorf("guest item grant collections: %w", err)
}
defer itemRows.Close()
for itemRows.Next() {
var id string
if err := itemRows.Scan(&id); err != nil {
return nil, err
}
ids[id] = true
}
result := make([]string, 0, len(ids))
for id := range ids {
result = append(result, id)
}
return result, nil
}
// GuestVisibleResourcesIncludeDeleted is the delta-sync variant of
// GuestVisibleResources: it does NOT filter out soft-deleted items
// or collections. /items-changes (TASK-1354) must surface a
// tombstone for any item the client previously saw, including items
// the user had an item-level grant on. Without this variant, a
// grant-only user whose granted item gets soft-deleted would see
// the row vanish from /items-changes with no `deleted:true` signal,
// and the client would keep the stale entry in its local index
// forever (Codex review of TASK-1354 round 1 [P1]).
//
// Same shape as GuestVisibleResources so the caller can swap them
// in/out depending on whether the endpoint needs live-state or
// tombstone-bearing visibility.
func (s *Store) GuestVisibleResourcesIncludeDeleted(workspaceID, userID string) (fullCollectionIDs []string, grantedItemIDs []string, err error) {
// Collections with direct grants — include soft-deleted
// collections so the client can flush their items from its
// local index via the items query below.
rows, err := s.db.Query(s.q(`
SELECT DISTINCT cg.collection_id FROM collection_grants cg
WHERE cg.workspace_id = ? AND cg.user_id = ?
`), workspaceID, userID)
if err != nil {
return nil, nil, fmt.Errorf("guest collection grants (include deleted): %w", err)
}
defer rows.Close()
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, nil, err
}
fullCollectionIDs = append(fullCollectionIDs, id)
}
if err := rows.Err(); err != nil {
return nil, nil, err
}
// Item-level grants — INCLUDE soft-deleted items so their
// tombstones flow through /items-changes. The grant row itself
// is the source of truth for visibility; the item's
// deleted_at is what the delta endpoint USES to mark
// `deleted:true` on the wire.
itemRows, err := s.db.Query(s.q(`
SELECT DISTINCT ig.item_id
FROM item_grants ig
WHERE ig.workspace_id = ? AND ig.user_id = ?
`), workspaceID, userID)
if err != nil {
return nil, nil, fmt.Errorf("guest item grants (include deleted): %w", err)
}
defer itemRows.Close()
for itemRows.Next() {
var id string
if err := itemRows.Scan(&id); err != nil {
return nil, nil, err
}
grantedItemIDs = append(grantedItemIDs, id)
}
if err := itemRows.Err(); err != nil {
return nil, nil, err
}
return fullCollectionIDs, grantedItemIDs, nil
}
// GuestVisibleResources returns the two-level visibility for a guest:
// - fullCollectionIDs: collections where the user has a direct collection grant (full access)
// - grantedItemIDs: specific item IDs the user has item-level grants on
// This allows callers to distinguish between full-collection access and item-only access.
func (s *Store) GuestVisibleResources(workspaceID, userID string) (fullCollectionIDs []string, grantedItemIDs []string, err error) {
// Collections with direct grants (full collection access, excluding soft-deleted)
rows, err := s.db.Query(s.q(`
SELECT DISTINCT cg.collection_id FROM collection_grants cg
JOIN collections c ON c.id = cg.collection_id
WHERE cg.workspace_id = ? AND cg.user_id = ? AND c.deleted_at IS NULL
`), workspaceID, userID)
if err != nil {
return nil, nil, fmt.Errorf("guest collection grants: %w", err)
}
defer rows.Close()
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, nil, err
}
fullCollectionIDs = append(fullCollectionIDs, id)
}
if err := rows.Err(); err != nil {
return nil, nil, err
}
// Individual item IDs with direct grants (excluding soft-deleted items/collections)
itemRows, err := s.db.Query(s.q(`
SELECT DISTINCT ig.item_id
FROM item_grants ig
JOIN items i ON i.id = ig.item_id
JOIN collections c ON c.id = i.collection_id
WHERE ig.workspace_id = ? AND ig.user_id = ? AND i.deleted_at IS NULL AND c.deleted_at IS NULL
`), workspaceID, userID)
if err != nil {
return nil, nil, fmt.Errorf("guest item grants: %w", err)
}
defer itemRows.Close()
for itemRows.Next() {
var id string
if err := itemRows.Scan(&id); err != nil {
return nil, nil, err
}
grantedItemIDs = append(grantedItemIDs, id)
}
if err := itemRows.Err(); err != nil {
return nil, nil, err
}
return fullCollectionIDs, grantedItemIDs, nil
}