Skip to content

fix(api): prevent IDOR in TenantNamespace Get and Watch handlers - #2471

Merged
myasnikovdaniil merged 4 commits into
mainfrom
feat/fix-tenant-idor
Apr 28, 2026
Merged

fix(api): prevent IDOR in TenantNamespace Get and Watch handlers#2471
myasnikovdaniil merged 4 commits into
mainfrom
feat/fix-tenant-idor

Conversation

@IvanHunters

@IvanHunters IvanHunters commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixed two IDOR (Insecure Direct Object Reference) vulnerabilities in the TenantNamespace API handlers that allowed authenticated users to access metadata of tenant namespaces without proper authorization checks.

Changes

New optimized function: hasAccessToNamespace()

  • Lists RoleBindings only in the target namespace instead of all cluster RoleBindings
  • Used by Get() and Watch() for single-namespace access checks
  • Order of magnitude faster than the previous approach

Get() handler

  • Uses hasAccessToNamespace() for efficient authorization
  • Returns NotFound instead of Forbidden to prevent tenant enumeration
  • Now correctly enforces RoleBinding-based access control

Watch() handler

  • Uses hasAccessToNamespace() for per-event authorization
  • Logs authorization errors with klog for security audit
  • Events for unauthorized namespaces are silently filtered out
  • Ensures users only receive watch events for namespaces they have access to

Additional fixes

  • Fixed ServiceAccount subject handling when namespace is empty (defaults to RoleBinding namespace)
  • Added proper error logging in Watch handler

Performance Impact

Handler Before After
List() List all RoleBindings × 1 No change ✅
Get() List all RoleBindings × 1 List RoleBindings in 1 namespace 🚀
Watch() List all RoleBindings × N events List RoleBindings in 1 namespace × N events 🚀

For Watch with 100 events:

  • Before: 100 × (all cluster RoleBindings) = catastrophic
  • After: 100 × (1-5 RoleBindings in namespace) = fast + cached by controller-runtime

Security Impact

Before: Any authenticated user could:

  • Read metadata (labels, annotations, creation time) of any tenant-* namespace via Get()
  • Stream all tenant namespace events via Watch(), including creation/modification/deletion

After: Users can only access tenant namespaces they have explicit RoleBindings for, matching the behavior of the List() handler.

Testing

Manually verified:

  • Users can only get their own tenant namespaces
  • Users can only watch events for their own tenant namespaces
  • Unauthorized access returns NotFound (not Forbidden) to prevent enumeration
  • List() behavior remains unchanged and consistent with Get() and Watch()
  • Authorization errors are logged for security audit

Checklist

  • Code follows project style and conventions
  • Security vulnerability is fully mitigated
  • Authorization logic is consistent across List/Get/Watch handlers
  • Performance optimized per feedback from @timofei.larkin
  • Error logging added for security audit
  • Unit tests added (can be done in follow-up PR)

Summary by CodeRabbit

  • Bug Fixes

    • Enforced per-namespace access control for Get and Watch; inaccessible namespaces return Forbidden or NotFound as appropriate
    • Forwarded field/label selectors to upstream watches and added defensive filtering to skip inaccessible events (logged)
    • Improved ServiceAccount subject namespace fallback and privileged-group bypass
  • Tests

    • Added security tests covering RoleBinding subjects, groups, privileged bypasses, ServiceAccounts, and access-denied behaviors

@coderabbitai

coderabbitai Bot commented Apr 23, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9a878c73-e2ed-46e1-b507-d602eb7619d3

📥 Commits

Reviewing files that changed from the base of the PR and between 7257b6a and 61ed7ad.

📒 Files selected for processing (2)
  • pkg/registry/core/tenantnamespace/rest.go
  • pkg/registry/core/tenantnamespace/rest_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • pkg/registry/core/tenantnamespace/rest.go

📝 Walkthrough

Walkthrough

Adds per-namespace authorization to tenant-namespace REST: Get enforces single-namespace access checks and avoids enumeration; Watch forwards selectors upstream, caches requester identity, and filters events by per-namespace access. ServiceAccount subject matching and namespace-fallback were refactored; tests cover IDOR scenarios.

Changes

Cohort / File(s) Summary
Access Control Implementation
pkg/registry/core/tenantnamespace/rest.go
Adds hasAccessToNamespace / hasAccessToNamespaceForUser; enforces access in Get; updates Watch to forward FieldSelector/LabelSelector, cache requester identity, and apply per-event access filtering; extracts matchesSubject (ServiceAccount namespace fallback) and privileged-group bypass.
Security Test Suite
pkg/registry/core/tenantnamespace/rest_test.go
Adds comprehensive tests for hasAccessToNamespace and REST.Get covering user, group, privileged-group (system:masters, cozystack-cluster-admin), ServiceAccount (including empty-namespace fallback), denied access, and NotFound to prevent namespace enumeration.

Sequence Diagram(s)

sequenceDiagram
  participant Client as Client
  participant REST as REST Tier
  participant RB as RoleBindingStore
  participant Up as UpstreamWatch

  Client->>REST: GET /tenantnamespaces/{name}
  REST->>RB: List RoleBindings (ns = target)
  RB-->>REST: RoleBindings
  REST->>REST: hasAccessToNamespaceForUser
  alt access allowed
    REST-->>Client: 200 TenantNamespace
  else access denied
    REST-->>Client: 403 / 404
  end

  Client->>REST: WATCH /tenantnamespaces?fieldSelector=...&labelSelector=...
  REST->>Up: Start upstream watch (with selectors)
  Up-->>REST: Event(namespace X)
  REST->>REST: lookup cached requester identity
  REST->>RB: List RoleBindings (ns = X)
  RB-->>REST: RoleBindings
  REST->>REST: hasAccessToNamespaceForUser
  alt access allowed
    REST-->>Client: Event(namespace X)
  else skip
    REST--xClient: (event skipped)
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I hop through binds and roles tonight,

Checking names with careful sight,
Skipping doors I must not throw,
Quiet guards let only few go,
A little rabbit keeps access right.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and clearly summarizes the main security fix: preventing IDOR vulnerabilities in TenantNamespace Get and Watch handlers, which matches the core purpose of the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/fix-tenant-idor

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@dosubot dosubot Bot added the lgtm This PR has been approved by a maintainer label Apr 23, 2026
Fixed two IDOR vulnerabilities allowing authenticated users to access
metadata of any tenant namespace without proper authorization.

Changes:
- Added hasAccessToNamespace() for efficient single-namespace access checks
- Get() now verifies access before returning namespace metadata
- Watch() filters events per-namespace with proper authorization
- Returns NotFound (not Forbidden) to prevent tenant enumeration

Performance optimization:
- hasAccessToNamespace() lists RoleBindings only in target namespace
  instead of listing all cluster RoleBindings (order of magnitude faster)
- Watch handler logs authorization errors for security audit

Additional fixes:
- Handle ServiceAccount subjects with empty namespace correctly
- Add klog error logging for failed authorization checks

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
Added comprehensive unit tests for authorization logic:

- hasAccessToNamespace() tests:
  - User subject access (positive and negative cases)
  - Group subject access
  - ServiceAccount subject access
  - ServiceAccount with empty namespace (defaults to RoleBinding ns)
  - Privileged groups (system:masters, cozystack-cluster-admin)

- Get() handler tests:
  - Returns namespace when user has access
  - Returns NotFound when user lacks access (not Forbidden)
  - Returns NotFound for non-tenant namespaces

All tests verify that authorization correctly enforces RoleBinding-based
access control and prevents IDOR vulnerabilities.

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
@IvanHunters
IvanHunters marked this pull request as ready for review April 23, 2026 15:21
@dosubot dosubot Bot added the size/M This PR changes 30-99 lines, ignoring generated files label Apr 23, 2026
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses critical security vulnerabilities in the TenantNamespace API by implementing granular authorization checks. By shifting from cluster-wide RoleBinding lookups to namespace-scoped queries, the changes not only secure the API against unauthorized access but also provide a substantial performance improvement for Get and Watch operations.

Highlights

  • Security Enhancement: Mitigated IDOR vulnerabilities in TenantNamespace Get and Watch handlers by enforcing strict namespace-level authorization checks.
  • Performance Optimization: Introduced an optimized hasAccessToNamespace function that queries RoleBindings only within the target namespace, significantly reducing API load.
  • Information Leakage Prevention: Updated authorization failures to return NotFound instead of Forbidden to prevent tenant enumeration.
  • Robustness: Improved ServiceAccount subject handling and added comprehensive security audit logging for unauthorized access attempts.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment Gemini (@gemini-code-assist) Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on Gemini (@gemini-code-assist) comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@dosubot dosubot Bot added the kind/bug Categorizes issue or PR as related to a bug label Apr 23, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements access control checks for Get and Watch operations on TenantNamespace resources to prevent Insecure Direct Object Reference (IDOR) vulnerabilities. It introduces a hasAccessToNamespace helper that verifies user permissions via RoleBindings within the target namespace and includes comprehensive unit tests for these security checks. Feedback focuses on optimizing the Watch loop by caching user group information, refactoring duplicated subject-matching logic into a shared helper to improve maintainability, and adopting structured logging (klog.ErrorS) to align with project standards.

}

// Check if user has access to this namespace
hasAccess, err := r.hasAccessToNamespace(ctx, ns.Name)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Calling hasAccessToNamespace inside the watch loop is inefficient because it re-retrieves the user from the context and re-builds the groups map for every single event. Since the user's identity and groups are static for the duration of the watch request, consider extracting the user info and groups map once before starting the goroutine and passing them to a helper function for the access check.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Hoisted username and the groups map out of the watch goroutine in commit 61ed7ad, and added an inner helper hasAccessToNamespaceForUser that takes the cached identity. The watch loop now does only the per-namespace RoleBinding list and subject match per event.

Comment on lines +434 to +456
for i := range rbs.Items {
for j := range rbs.Items[i].Subjects {
subj := rbs.Items[i].Subjects[j]
switch subj.Kind {
case "Group":
if _, ok := groups[subj.Name]; ok {
return true, nil
}
case "User":
if subj.Name == u.GetName() {
return true, nil
}
case "ServiceAccount":
saNamespace := subj.Namespace
if saNamespace == "" {
saNamespace = rbs.Items[i].Namespace
}
if u.GetName() == fmt.Sprintf("system:serviceaccount:%s:%s", saNamespace, subj.Name) {
return true, nil
}
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic for matching a user against RoleBinding subjects is duplicated between filterAccessible and hasAccessToNamespace. This includes the logic for Group, User, and the recently fixed ServiceAccount namespace handling. Refactoring this into a shared helper method (e.g., userMatchesSubjects) would improve maintainability and ensure consistency across the List, Get, and Watch handlers.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed in commit 7257b6ae — both filterAccessible and hasAccessToNamespace now go through the shared matchesSubject helper (rest.go:360), which centralizes Group/User/ServiceAccount matching including the empty-namespace fallback.

// Check if user has access to this namespace
hasAccess, err := r.hasAccessToNamespace(ctx, ns.Name)
if err != nil {
klog.Errorf("Failed to check access for namespace %s in watch: %v", ns.Name, err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low

Use structured logging with klog.ErrorS instead of klog.Errorf to adhere to the project's Go code standards and Kubernetes best practices for structured logging.

Suggested change
klog.Errorf("Failed to check access for namespace %s in watch: %v", ns.Name, err)
klog.ErrorS(err, "Failed to check access for namespace in watch", "namespace", ns.Name)
References
  1. Use structured logging via logr — flag fmt.Print* and log.Print* in controller code. (link)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched to klog.ErrorS in commit 61ed7ad.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
pkg/registry/core/tenantnamespace/rest.go (1)

156-161: ⚠️ Potential issue | 🔴 Critical

Propagate field and label selectors in Watch before authorizing and emitting events.

Watch accepts opts but ignores opts.FieldSelector and opts.LabelSelector before streaming events to the client. When a watch request is name-scoped (e.g., fieldSelector=metadata.name=specific-ns), the Kubernetes API server's RBAC rules with resourceNames correctly restrict the request to that single object. However, this handler emits all accessible tenant namespaces instead, bypassing the selector restriction. Either propagate both selectors to the upstream Watch call or apply in-process filtering before sending events.

Suggested approach
 nsWatch, err := r.w.Watch(ctx, nsList, &client.ListOptions{Raw: &metav1.ListOptions{
 	Watch:           true,
 	ResourceVersion: opts.ResourceVersion,
+	FieldSelector:   opts.FieldSelector.String(),
+	LabelSelector:   opts.LabelSelector.String(),
 }})

Then add defensive filtering before authorization:

 ns, ok := ev.Object.(*corev1.Namespace)
 if !ok || !strings.HasPrefix(ns.Name, prefix) {
 	continue
 }
+if opts.FieldSelector != nil && !opts.FieldSelector.Matches(fields.Set{"metadata.name": ns.Name}) {
+	continue
+}
+if opts.LabelSelector != nil && !opts.LabelSelector.Matches(labels.Set(ns.Labels)) {
+	continue
+}
 
 hasAccess, err := r.hasAccessToNamespace(ctx, ns.Name)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/registry/core/tenantnamespace/rest.go` around lines 156 - 161, The Watch
method ignores opts.FieldSelector and opts.LabelSelector causing it to stream
all namespaces; update Watch (function Watch and the r.w.Watch call) to
propagate opts.FieldSelector and opts.LabelSelector into the upstream
client.ListOptions/Metav1.ListOptions so the upstream watch is already filtered,
and if for any reason you cannot pass selectors upstream, apply the same
filtering logic in-process when reading from nsWatch.ResultChan() (filter events
by metadata.name and labels based on opts.FieldSelector/opts.LabelSelector)
before authorization/emitting events to the caller to ensure selector
restrictions are preserved.
🧹 Nitpick comments (1)
pkg/registry/core/tenantnamespace/rest.go (1)

383-388: Consider sharing the subject-matching logic.

The ServiceAccount fallback fix now exists in both filterAccessible and hasAccessToNamespace. Extracting a small helper avoids future drift in this security-sensitive check.

♻️ Proposed helper shape
+func subjectMatchesUser(subj rbacv1.Subject, bindingNamespace string, username string, groups map[string]struct{}) bool {
+	switch subj.Kind {
+	case "Group":
+		_, ok := groups[subj.Name]
+		return ok
+	case "User":
+		return subj.Name == username
+	case "ServiceAccount":
+		saNamespace := subj.Namespace
+		if saNamespace == "" {
+			saNamespace = bindingNamespace
+		}
+		return username == fmt.Sprintf("system:serviceaccount:%s:%s", saNamespace, subj.Name)
+	default:
+		return false
+	}
+}

Also applies to: 433-452

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/registry/core/tenantnamespace/rest.go` around lines 383 - 388, Both
filterAccessible and hasAccessToNamespace duplicate the ServiceAccount
subject-matching/fallback logic; extract that check into a shared helper (e.g.,
matchesServiceAccountSubject or isServiceAccountUserMatch) and call it from both
places. The helper should accept the Subject (subj), the resource item's
Namespace (rbs.Items[i].Namespace or itemNamespace), and the user name
(u.GetName()), implement the same fallback where subj.Namespace=="" uses the
resource namespace, and perform the comparison against
fmt.Sprintf("system:serviceaccount:%s:%s", namespace, subj.Name); replace the
duplicated blocks in filterAccessible and hasAccessToNamespace with calls to
this helper.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@pkg/registry/core/tenantnamespace/rest.go`:
- Around line 156-161: The Watch method ignores opts.FieldSelector and
opts.LabelSelector causing it to stream all namespaces; update Watch (function
Watch and the r.w.Watch call) to propagate opts.FieldSelector and
opts.LabelSelector into the upstream client.ListOptions/Metav1.ListOptions so
the upstream watch is already filtered, and if for any reason you cannot pass
selectors upstream, apply the same filtering logic in-process when reading from
nsWatch.ResultChan() (filter events by metadata.name and labels based on
opts.FieldSelector/opts.LabelSelector) before authorization/emitting events to
the caller to ensure selector restrictions are preserved.

---

Nitpick comments:
In `@pkg/registry/core/tenantnamespace/rest.go`:
- Around line 383-388: Both filterAccessible and hasAccessToNamespace duplicate
the ServiceAccount subject-matching/fallback logic; extract that check into a
shared helper (e.g., matchesServiceAccountSubject or isServiceAccountUserMatch)
and call it from both places. The helper should accept the Subject (subj), the
resource item's Namespace (rbs.Items[i].Namespace or itemNamespace), and the
user name (u.GetName()), implement the same fallback where subj.Namespace==""
uses the resource namespace, and perform the comparison against
fmt.Sprintf("system:serviceaccount:%s:%s", namespace, subj.Name); replace the
duplicated blocks in filterAccessible and hasAccessToNamespace with calls to
this helper.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 52444651-b888-4c34-8b86-4ee7609b7072

📥 Commits

Reviewing files that changed from the base of the PR and between 7e887ed and 5b2501d.

📒 Files selected for processing (2)
  • pkg/registry/core/tenantnamespace/rest.go
  • pkg/registry/core/tenantnamespace/rest_test.go

// Check if user has access to this namespace
hasAccess, err := r.hasAccessToNamespace(ctx, name)
if err != nil {
return nil, err

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returning 403 error if user doesn't have enough privileges better maps to the kube api rbac strategy.

  • Resource exists but user doesn't have access? 403 "user is not allowed to get resources of kind X in namespace Y"
  • Resource doesn't exist and user doesn't have access? 403 "user is not allowed to get resource of kind X in namespace Y"
  • Resource doesn't exist and user has access? 404 "resource X does not exist"
  • Resource exist and user has access? 200

Now the error probably will be 500 since it's a custom error

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in commit 7257b6ae — denied access now returns apierrors.NewForbidden instead of NewNotFound, matching the kube RBAC convention you described.

@dosubot dosubot Bot removed the lgtm This PR has been approved by a maintainer label Apr 24, 2026
Address review feedback from sircthulhu and CodeRabbit:

1. Return Forbidden instead of NotFound for unauthorized access
   - Get() now returns 403 Forbidden to follow standard K8s RBAC behavior
   - Previously returned 404 NotFound for security-by-obscurity
   - Updated test expectations to match new behavior

2. Propagate field and label selectors in Watch handler
   - Pass opts.FieldSelector and opts.LabelSelector to upstream Watch
   - Add defensive filtering before authorization to prevent RBAC bypass
   - Fixes potential issue with resourceNames restrictions

3. Refactor subject-matching logic to eliminate duplication
   - Extract matchesSubject() helper for Group/User/ServiceAccount checks
   - Remove duplicated code from filterAccessible and hasAccessToNamespace
   - Consolidates ServiceAccount namespace fallback logic

All tests pass successfully.

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
@dosubot dosubot Bot added size/L This PR changes 100-499 lines, ignoring generated files and removed size/M This PR changes 30-99 lines, ignoring generated files labels Apr 28, 2026
@github-actions github-actions Bot added the area/api Issues or PRs related to the cozystack-api aggregated API server label Apr 28, 2026

@sircthulhu Kirill Ilin (sircthulhu) left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM with non blocking observation: If I'm not mistaken, returned errors should be typed, so that kubernetes could add correct response code

@dosubot dosubot Bot added the lgtm This PR has been approved by a maintainer label Apr 28, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pkg/registry/core/tenantnamespace/rest_test.go`:
- Around line 367-414: The test TestGet_WithAccess only checks obj != nil which
allows wrong types; update the test to assert that the returned object from
REST.Get is a *TenantNamespace (or the concrete type used by your API) and
verify its metadata (at minimum the Name equals "tenant-test"); locate the call
to r.Get in TestGet_WithAccess and add a type assertion to the concrete
TenantNamespace type, check for assertion success, and assert tn.ObjectMeta.Name
(or tn.GetName()) equals "tenant-test" so the test guarantees correct runtime
type and metadata.
- Around line 50-501: Add regression tests covering REST.Watch's per-event
authorization filtering by creating mixed streams of RoleBindings/namespaces so
some events should be filtered and asserting only authorized namespace events
are delivered and label/field selectors are preserved; also strengthen
TestGet_WithAccess to validate the returned object's concrete type and metadata
(Kind, APIVersion, Name/Namespace/Labels) from REST.Get and ensure it matches
the tenant namespace resource shape rather than only checking non-nil. Locate
tests around hasAccessToNamespace, Get, and Watch in the REST test file to
implement these assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: fa3e78d7-5691-4943-aa2b-9ceed6cb7ace

📥 Commits

Reviewing files that changed from the base of the PR and between 5b2501d and 7257b6a.

📒 Files selected for processing (2)
  • pkg/registry/core/tenantnamespace/rest.go
  • pkg/registry/core/tenantnamespace/rest_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • pkg/registry/core/tenantnamespace/rest.go

Comment on lines +50 to +501
// Security tests for IDOR fix

func TestHasAccessToNamespace_WithUserAccess(t *testing.T) {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
_ = rbacv1.AddToScheme(scheme)

ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"},
}

rb := &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "test-binding",
Namespace: "tenant-test",
},
Subjects: []rbacv1.Subject{
{Kind: "User", Name: "test-user", APIGroup: "rbac.authorization.k8s.io"},
},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Kind: "Role",
Name: "test-role",
},
}

client := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(ns, rb).
Build()

r := &REST{
c: client,
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
}

u := &user.DefaultInfo{
Name: "test-user",
Groups: []string{"system:authenticated"},
}
ctx := request.WithUser(context.Background(), u)

hasAccess, err := r.hasAccessToNamespace(ctx, "tenant-test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !hasAccess {
t.Error("expected user to have access, but got false")
}
}

func TestHasAccessToNamespace_WithoutAccess(t *testing.T) {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
_ = rbacv1.AddToScheme(scheme)

ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"},
}

// RoleBinding for different user
rb := &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "test-binding",
Namespace: "tenant-test",
},
Subjects: []rbacv1.Subject{
{Kind: "User", Name: "other-user", APIGroup: "rbac.authorization.k8s.io"},
},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Kind: "Role",
Name: "test-role",
},
}

client := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(ns, rb).
Build()

r := &REST{
c: client,
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
}

u := &user.DefaultInfo{
Name: "test-user",
Groups: []string{"system:authenticated"},
}
ctx := request.WithUser(context.Background(), u)

hasAccess, err := r.hasAccessToNamespace(ctx, "tenant-test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if hasAccess {
t.Error("expected user to NOT have access, but got true")
}
}

func TestHasAccessToNamespace_WithGroupAccess(t *testing.T) {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
_ = rbacv1.AddToScheme(scheme)

ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"},
}

rb := &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "test-binding",
Namespace: "tenant-test",
},
Subjects: []rbacv1.Subject{
{Kind: "Group", Name: "test-group", APIGroup: "rbac.authorization.k8s.io"},
},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Kind: "Role",
Name: "test-role",
},
}

client := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(ns, rb).
Build()

r := &REST{
c: client,
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
}

u := &user.DefaultInfo{
Name: "test-user",
Groups: []string{"system:authenticated", "test-group"},
}
ctx := request.WithUser(context.Background(), u)

hasAccess, err := r.hasAccessToNamespace(ctx, "tenant-test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !hasAccess {
t.Error("expected user to have access via group, but got false")
}
}

func TestHasAccessToNamespace_SystemMasters(t *testing.T) {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
_ = rbacv1.AddToScheme(scheme)

ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"},
}

client := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(ns).
Build()

r := &REST{
c: client,
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
}

u := &user.DefaultInfo{
Name: "admin",
Groups: []string{"system:masters"},
}
ctx := request.WithUser(context.Background(), u)

hasAccess, err := r.hasAccessToNamespace(ctx, "tenant-test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !hasAccess {
t.Error("expected system:masters to have access, but got false")
}
}

func TestHasAccessToNamespace_CozyAdminGroup(t *testing.T) {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
_ = rbacv1.AddToScheme(scheme)

ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"},
}

client := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(ns).
Build()

r := &REST{
c: client,
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
}

u := &user.DefaultInfo{
Name: "cozy-admin",
Groups: []string{"cozystack-cluster-admin"},
}
ctx := request.WithUser(context.Background(), u)

hasAccess, err := r.hasAccessToNamespace(ctx, "tenant-test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !hasAccess {
t.Error("expected cozystack-cluster-admin to have access, but got false")
}
}

func TestHasAccessToNamespace_ServiceAccount(t *testing.T) {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
_ = rbacv1.AddToScheme(scheme)

ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"},
}

rb := &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "test-binding",
Namespace: "tenant-test",
},
Subjects: []rbacv1.Subject{
{Kind: "ServiceAccount", Name: "test-sa", Namespace: "tenant-test"},
},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Kind: "Role",
Name: "test-role",
},
}

client := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(ns, rb).
Build()

r := &REST{
c: client,
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
}

u := &user.DefaultInfo{
Name: "system:serviceaccount:tenant-test:test-sa",
Groups: []string{"system:authenticated"},
}
ctx := request.WithUser(context.Background(), u)

hasAccess, err := r.hasAccessToNamespace(ctx, "tenant-test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !hasAccess {
t.Error("expected service account to have access, but got false")
}
}

func TestHasAccessToNamespace_ServiceAccountEmptyNamespace(t *testing.T) {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
_ = rbacv1.AddToScheme(scheme)

ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"},
}

// ServiceAccount subject with empty namespace should default to RoleBinding namespace
rb := &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "test-binding",
Namespace: "tenant-test",
},
Subjects: []rbacv1.Subject{
{Kind: "ServiceAccount", Name: "test-sa", Namespace: ""}, // Empty namespace
},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Kind: "Role",
Name: "test-role",
},
}

client := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(ns, rb).
Build()

r := &REST{
c: client,
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
}

u := &user.DefaultInfo{
Name: "system:serviceaccount:tenant-test:test-sa",
Groups: []string{"system:authenticated"},
}
ctx := request.WithUser(context.Background(), u)

hasAccess, err := r.hasAccessToNamespace(ctx, "tenant-test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !hasAccess {
t.Error("expected service account with empty namespace to have access, but got false")
}
}

func TestGet_WithAccess(t *testing.T) {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
_ = rbacv1.AddToScheme(scheme)

ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"},
}

rb := &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "test-binding",
Namespace: "tenant-test",
},
Subjects: []rbacv1.Subject{
{Kind: "User", Name: "test-user", APIGroup: "rbac.authorization.k8s.io"},
},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Kind: "Role",
Name: "test-role",
},
}

client := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(ns, rb).
Build()

r := &REST{
c: client,
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
}

u := &user.DefaultInfo{
Name: "test-user",
Groups: []string{"system:authenticated"},
}
ctx := request.WithUser(context.Background(), u)

obj, err := r.Get(ctx, "tenant-test", &metav1.GetOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if obj == nil {
t.Fatal("expected object, got nil")
}
}

func TestGet_WithoutAccess(t *testing.T) {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
_ = rbacv1.AddToScheme(scheme)

ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"},
}

// RoleBinding for different user
rb := &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "test-binding",
Namespace: "tenant-test",
},
Subjects: []rbacv1.Subject{
{Kind: "User", Name: "other-user", APIGroup: "rbac.authorization.k8s.io"},
},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Kind: "Role",
Name: "test-role",
},
}

client := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(ns, rb).
Build()

r := &REST{
c: client,
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
}

u := &user.DefaultInfo{
Name: "test-user",
Groups: []string{"system:authenticated"},
}
ctx := request.WithUser(context.Background(), u)

obj, err := r.Get(ctx, "tenant-test", &metav1.GetOptions{})
if err == nil {
t.Fatal("expected error, got nil")
}
if obj != nil {
t.Errorf("expected nil object, got %v", obj)
}

// Verify it returns Forbidden to follow standard K8s RBAC behavior
if !apierrors.IsForbidden(err) {
t.Errorf("expected Forbidden error, got %v", err)
}
}

func TestGet_NonTenantNamespace(t *testing.T) {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
_ = rbacv1.AddToScheme(scheme)

client := fake.NewClientBuilder().
WithScheme(scheme).
Build()

r := &REST{
c: client,
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
}

u := &user.DefaultInfo{
Name: "test-user",
Groups: []string{"system:masters"},
}
ctx := request.WithUser(context.Background(), u)

obj, err := r.Get(ctx, "default", &metav1.GetOptions{})
if err == nil {
t.Fatal("expected error for non-tenant namespace, got nil")
}
if obj != nil {
t.Errorf("expected nil object, got %v", obj)
}
if !apierrors.IsNotFound(err) {
t.Errorf("expected NotFound error, got %v", err)
}
}

@coderabbitai coderabbitai Bot Apr 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether tenantnamespace already has dedicated Watch tests elsewhere.
# Expected: security coverage for REST.Watch, especially mixed streams and unauthorized-event filtering.
rg -n --type=go -C3 'func Test.*Watch|\.Watch\(' pkg/registry/core/tenantnamespace

Repository: cozystack/cozystack

Length of output: 556


🏁 Script executed:

# Get the full Watch implementation from rest.go
head -200 pkg/registry/core/tenantnamespace/rest.go | tail -50

Repository: cozystack/cozystack

Length of output: 1496


🏁 Script executed:

# Check full rest.go around the Watch method
sed -n '150,200p' pkg/registry/core/tenantnamespace/rest.go

Repository: cozystack/cozystack

Length of output: 1525


🏁 Script executed:

# Search entire rest_test.go for any Watch reference (not just test functions)
rg -n --type=go 'Watch' pkg/registry/core/tenantnamespace/rest_test.go

Repository: cozystack/cozystack

Length of output: 45


🏁 Script executed:

# Confirm the file size and check if there's more content beyond line 501
wc -l pkg/registry/core/tenantnamespace/rest_test.go

Repository: cozystack/cozystack

Length of output: 114


🏁 Script executed:

# Check for any Watch tests in sibling test files or integration tests
find pkg/registry/core/tenantnamespace -name '*test*.go' -type f | xargs rg -l 'Watch' 2>/dev/null || echo "No Watch tests found"

Repository: cozystack/cozystack

Length of output: 84


🏁 Script executed:

# Get the full Watch implementation to see if it includes IDOR/authorization checks
sed -n '150,250p' pkg/registry/core/tenantnamespace/rest.go

Repository: cozystack/cozystack

Length of output: 2901


🏁 Script executed:

# Check if Watch calls hasAccessToNamespace or any authorization logic
rg -A30 'func \(r \*REST\) Watch' pkg/registry/core/tenantnamespace/rest.go

Repository: cozystack/cozystack

Length of output: 938


🏁 Script executed:

# Get the complete Watch implementation by searching for the end of the method
rg -A80 'func \(r \*REST\) Watch' pkg/registry/core/tenantnamespace/rest.go | head -100

Repository: cozystack/cozystack

Length of output: 2282


🏁 Script executed:

# Check TestGet_WithAccess assertions more carefully
sed -n '361,410p' pkg/registry/core/tenantnamespace/rest_test.go

Repository: cozystack/cozystack

Length of output: 1266


🏁 Script executed:

# Check what the actual Get implementation returns
rg -B5 -A25 'func \(r \*REST\) Get' pkg/registry/core/tenantnamespace/rest.go | head -50

Repository: cozystack/cozystack

Length of output: 851


🏁 Script executed:

# Verify the full Get method to understand what object should be returned
sed -n '100,150p' pkg/registry/core/tenantnamespace/rest.go

Repository: cozystack/cozystack

Length of output: 1342


Add regression tests for Watch authorization filtering and strengthen Get assertions.

The Watch implementation includes per-event hasAccessToNamespace checks to filter unauthorized namespaces, but this critical security path has zero test coverage. This leaves the event-by-event authorization filtering vulnerable to silent regressions, especially mixed authorized/unauthorized streams and selector propagation.

Additionally, TestGet_WithAccess only verifies obj != nil; it does not assert the object type, Kind, APIVersion, or metadata fields, which would catch object wrapping or metadata mapping regressions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/registry/core/tenantnamespace/rest_test.go` around lines 50 - 501, Add
regression tests covering REST.Watch's per-event authorization filtering by
creating mixed streams of RoleBindings/namespaces so some events should be
filtered and asserting only authorized namespace events are delivered and
label/field selectors are preserved; also strengthen TestGet_WithAccess to
validate the returned object's concrete type and metadata (Kind, APIVersion,
Name/Namespace/Labels) from REST.Get and ensure it matches the tenant namespace
resource shape rather than only checking non-nil. Locate tests around
hasAccessToNamespace, Get, and Watch in the REST test file to implement these
assertions.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Get assertions strengthened in commit 61ed7ad to check the concrete *TenantNamespace type plus Name, Kind, and APIVersion. The dedicated Watch test suite (mixed authorized/unauthorized streams, selector propagation) is tracked as a follow-up — hasAccessToNamespace itself has full coverage and the Watch loop is a thin wrapper around it. Will land separately so this security fix can ship.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@myasnikovdaniil, thanks for the update!

The TestGet_WithAccess strengthening looks good in the current file — the type assertion to *corev1alpha1.TenantNamespace along with the Name, Kind, and APIVersion checks are exactly what was needed there.

For the Watch test suite, the deferral reasoning is sound — hasAccessToNamespace is fully covered and the per-event loop is a straightforward wrapper around it. Just make sure a tracking issue is opened so the follow-up doesn't slip. Would you like me to open one now?

Comment thread pkg/registry/core/tenantnamespace/rest_test.go
- Hoist user identity extraction out of the Watch goroutine; reuse a
  cached username and groups map across events instead of re-fetching
  them per event. Watch now returns Unauthorized up front when no user
  is present in the context, rather than failing silently per event.
- Switch the per-event access-check error log to structured klog.ErrorS
  to comply with the project Go style guide.
- Strengthen TestGet_WithAccess to assert the concrete *TenantNamespace
  type plus Name, Kind, and APIVersion, so type or metadata regressions
  fail fast.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
@myasnikovdaniil myasnikovdaniil added backport Should change be backported on previous release backport-previous Backport target — previous release line labels Apr 28, 2026
@myasnikovdaniil
myasnikovdaniil merged commit a961a90 into main Apr 28, 2026
13 checks passed
@myasnikovdaniil
myasnikovdaniil deleted the feat/fix-tenant-idor branch April 28, 2026 14:07
@github-actions

Copy link
Copy Markdown

Successfully created backport PR for release-1.2:

@github-actions

Copy link
Copy Markdown

Successfully created backport PR for release-1.3:

myasnikovdaniil added a commit that referenced this pull request Apr 28, 2026
…and Watch handlers (#2524)

# Description
Backport of #2471 to `release-1.3`.
myasnikovdaniil added a commit that referenced this pull request Apr 29, 2026
The auto-generated changelog only listed #2459/#2467 (velero-configmap
Role move). Five additional PRs were backported and merged into
release-1.3 between then and the v1.3.1 tag (2026-04-28):

- #2471/#2524 - fix(api): IDOR in TenantNamespace Get/Watch
- #2496/#2505 - feat(linstor): linstor-csi v1.10.6 (Protocol-C dual-attach)
- #2462/#2511 - fix(etcd): remove destructive post-upgrade hook
- #2421/#2491 - fix(kamaji): memory limits + startup probe
- #2498/#2518 - build(linstor): wire linstor-gui into root build target

Update the release date to match the actual tag (2026-04-28), rewrite
the intro paragraph, and add @kvaps to contributors.

Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
Andrei Kvapil (kvaps) added a commit that referenced this pull request Apr 30, 2026
…and Watch handlers (#2523)

# Description
Backport of #2471 to `release-1.2`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/api Issues or PRs related to the cozystack-api aggregated API server backport Should change be backported on previous release backport-previous Backport target — previous release line kind/bug Categorizes issue or PR as related to a bug lgtm This PR has been approved by a maintainer size/L This PR changes 100-499 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants