Skip to content
This repository was archived by the owner on Sep 9, 2026. It is now read-only.

OSAC-1812: Auto create IdP user - #782

Merged
openshift-merge-bot[bot] merged 2 commits into
osac-project:mainfrom
CrystalChun:jit
Jul 1, 2026
Merged

openshift-merge-bot[bot] merged 2 commits into
osac-project:mainfrom
CrystalChun:jit

Conversation

@CrystalChun

@CrystalChun CrystalChun commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Description

When an IdP user first logs in, we should create the user in our database for persistent records. This creates a new
interceptor to handle this case.

Testing

  • Created Org, IdP connection, logged in as IdP user

Assisted-by: Claude Code noreply@anthropic.com

Summary by CodeRabbit

  • New Features
    • Added just-in-time user provisioning for gRPC requests, automatically creating users when an authenticated request identifies a single eligible tenant.
  • Bug Fixes
    • Updated authorization to store the subject in request context before emitting access-grant logging.
    • Improved gRPC startup reliability by initializing tenancy logic earlier and wiring the JIT provisioning interceptor into unary and streaming request chains.
    • Enhanced table rendering fallback: missing or unloadable table definitions now safely use the default layout.
  • Tests
    • Added comprehensive coverage for JIT provisioning and user provisioning behavior.

@openshift-ci-robot

openshift-ci-robot commented Jun 25, 2026

Copy link
Copy Markdown

@CrystalChun: This pull request references OSAC-1812 which is a valid jira issue.

Warning: The referenced jira issue has an invalid target version for the target branch this PR targets: expected the sub-task to target the "5.0.0" version, but no target version was set.

Details

In response to this:

Description

When an IdP user first logs in, we should create
the user in our database for persistent records.

Testing

  • Created Org, IdP connection, logged in as IdP user

Assisted-by: Claude Code noreply@anthropic.com

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the openshift-eng/jira-lifecycle-plugin repository.

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a gRPC JIT user provisioning interceptor and a DAO-backed UserProvisioner that creates privatev1.User records from JWT claims on first access. The authz interceptor now stores the subject in context before logging. The gRPC startup chain includes the new interceptor after authz. Table rendering falls back to a default table when the embedded definition is missing.

Changes

JIT user provisioning

Layer / File(s) Summary
Authz context ordering fix
internal/auth/grpc_authz_interceptor.go
ContextWithSubject is now called before logger.DebugContext so the "Permission granted" log carries the updated subject context.
JIT provisioning interceptor contract and logic
internal/auth/grpc_jit_provisioning_interceptor.go
Defines UserProvisioner, builder, and GrpcJitProvisioningInterceptor with UnaryServer/StreamServer. provisionIfNeeded skips on no provisioner, no subject, non-finite/multi/system/shared tenant, missing token, or non-MapClaims; propagates provisioner errors to block handler invocation.
JIT interceptor tests
internal/auth/grpc_jit_provisioning_interceptor_test.go
Ginkgo suite with mockUserProvisioner and mockServerStream covering builder validation, all skip conditions, claims forwarding, and provisioner error propagation for both unary and stream paths.
DAO-backed user provisioner
internal/provisioners/user_provisioner.go
UserProvisioner.Provision checks for existing user via username filter, extracts email from JWT claims, and creates a privatev1.User via usersDAO; compile-time assertion verifies auth.UserProvisioner conformance.
User provisioner tests
internal/provisioners/user_provisioner_test.go
Integration suite with a real database container validating builder errors, email claim mapping, idempotency, and non-string email defaulting to empty.
gRPC startup wiring
internal/cmd/service/start/grpcserver/start_grpc_server_cmd.go
Tenancy logic is initialized earlier; a tenant-aware users DAO and JIT interceptor are constructed and appended to both unary and stream chains after authz.

Unrelated cleanup

Layer / File(s) Summary
Table renderer error softening
internal/rendering/table_renderer.go
loadTable returns (nil, nil) for missing embedded files; Render falls back to defaultTable() instead of propagating the error.
Keycloak test map type cleanup
internal/idp/keycloak/client_test.go
Three decoded payload map types changed from map[string]interface{} to map[string]any.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant GrpcAuthzInterceptor
    participant GrpcJitProvisioningInterceptor
    participant UserProvisioner
    participant usersDAO

    Client->>GrpcAuthzInterceptor: RPC request
    GrpcAuthzInterceptor->>GrpcAuthzInterceptor: ContextWithSubject(ctx, subject)
    GrpcAuthzInterceptor->>GrpcJitProvisioningInterceptor: ctx with Subject + Token
    GrpcJitProvisioningInterceptor->>GrpcJitProvisioningInterceptor: provisionIfNeeded(ctx)
    GrpcJitProvisioningInterceptor->>UserProvisioner: Provision(ctx, username, tenant, claims)
    UserProvisioner->>usersDAO: List(filter: spec.username==username, limit 1)
    alt user exists
        usersDAO-->>UserProvisioner: []User (non-empty)
        UserProvisioner-->>GrpcJitProvisioningInterceptor: nil
    else user missing
        usersDAO-->>UserProvisioner: []User (empty)
        UserProvisioner->>usersDAO: Create(privatev1.User)
        UserProvisioner-->>GrpcJitProvisioningInterceptor: nil
    end
    GrpcJitProvisioningInterceptor->>Client: invoke handler
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested labels

lgtm

Suggested reviewers

  • jhernand

Poem

A token arrives, a user unknown,
Claims parsed from JWT, a new record's sown.
One tenant, finite, not system nor shared—
The provisioner runs, the handler's prepared.
JIT at the gate: welcome aboard! 🎫

🚥 Pre-merge checks | ✅ 10 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (10 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: automatically creating IdP users on first login.
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.
No-Hardcoded-Secrets ✅ Passed No hardcoded secrets, embedded creds, or secret-like literal assignments appear in the changed files; token/test literals are ordinary non-secret data.
No-Weak-Crypto ✅ Passed No weak-crypto APIs, custom crypto, or secret/token comparisons were introduced in the edited files.
No-Injection-Vectors ✅ Passed No injection-prone APIs were added; the only user-derived string builds a CEL filter, and the DAO translator escapes string literals before SQL generation.
Container-Privileges ✅ Passed No PR diff touched container/K8s manifests; searches found no privileged/host* flags, and grpc-server securityContext stays non-root with allowPrivilegeEscalation:false.
No-Sensitive-Data-In-Logs ✅ Passed New provisioning logs use !username/!tenant, which this logger redacts to ***; no added token/email/password/host leakage found.
Ai-Attribution ✅ Passed PASS: HEAD commit includes Assisted-by: Claude Code <noreply@anthropic.com> and I found no Co-Authored-By AI trailer in the commit body.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@CrystalChun

Copy link
Copy Markdown
Contributor Author

/cc @jhernand

@openshift-ci
openshift-ci Bot requested a review from jhernand June 25, 2026 21:39

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/auth/grpc_authz_interceptor_jit_test.go`:
- Around line 33-42: The test file formatting is out of sync with Go style, so
update the declarations around GrpcAuthzInterceptor test setup and run gofmt on
internal/auth/grpc_authz_interceptor_jit_test.go to normalize spacing and
alignment. Make sure the variable block containing ctx, ctrl, interceptor,
MockUserProvisioner, and the provisioning state variables matches gofmt output.

In `@internal/auth/grpc_authz_interceptor.go`:
- Around line 616-620: The tenant selection in grpc authz provisioning is
nondeterministic because subject.Tenants.Inclusions()[0] depends on map
iteration order. Update the tenant derivation logic in the interceptor to use a
deterministic tenant source from the claims/policy, or explicitly avoid JIT
provisioning when subject.Tenants contains more than one tenant. Keep the fix
centered around the tenant selection block in the grpc_authz_interceptor flow.

In `@internal/database/dao/user_provisioner_test.go`:
- Around line 312-324: The EnsureUserExists failure-case test is using a context
that fails too early in the List path, so it never reaches the create-failure
branch. Update the test in user_provisioner_test.go around EnsureUserExists to
use a context/mocks setup where the lookup succeeds and only the DAO Create().Do
call fails, so the non-panicking behavior is exercised for the create error
path.

In `@internal/database/dao/user_provisioner.go`:
- Around line 75-79: The user lookup filter in user_provisioner’s provisioning
flow is built by concatenating the JWT-derived username directly into the
predicate, which allows quote or operator injection and can break the query.
Update the lookup in the provisioner method that calls
usersDAO.List().SetFilter(...).SetLimit(1).Do(ctx) to use an escaping or
parameterized filter helper instead of fmt.Sprintf/string concatenation, and
validate the username at the trust boundary with an allow-list before
constructing the filter.
- Around line 74-90: The existence check in UserProvisioner.ProvisionUser
currently only filters by username, which can block provisioning across
different tenants. Update the list lookup in p.usersDAO.List() to include the
same tenant/organization scope used by the create path, using the existing
username plus the tenant/org field from the current request so only users in
that tenant suppress JIT provisioning.
- Around line 143-147: The provisioning log in user_provisioner.go is emitting
PII by including the email address in the InfoContext call. Update the Created
user via just-in-time provisioning log in the UserProvisioner flow to omit the
email field entirely, or if it must remain, change the key to a protected !email
field so the slog redaction handler can strip it. Keep the username and tenant
fields as the audit context.
- Around line 58-69: The DAOUserProvisionerBuilder.Build method currently only
validates logger and trusts usersDAO, which can defer a configuration error
until runtime. Update Build in DAOUserProvisionerBuilder to fail fast by
checking usersDAO for nil before constructing DAOUserProvisioner, and return a
clear error alongside the existing logger validation so the misconfiguration is
caught during startup instead of on the first request.
🪄 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: Repository: osac-project/coderabbit/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: 12321953-64cf-4e2b-ab62-fb304c3a0d50

📥 Commits

Reviewing files that changed from the base of the PR and between 59f6043 and e607988.

📒 Files selected for processing (6)
  • internal/auth/grpc_authz_interceptor.go
  • internal/auth/grpc_authz_interceptor_jit_test.go
  • internal/auth/user_provisioner_mock.go
  • internal/cmd/service/start/grpcserver/start_grpc_server_cmd.go
  • internal/database/dao/user_provisioner.go
  • internal/database/dao/user_provisioner_test.go

Comment thread internal/auth/grpc_authz_interceptor_jit_test.go Outdated
Comment thread internal/auth/grpc_authz_interceptor.go Outdated
Comment on lines +312 to +324
It("Does not crash when DAO create fails", func() {
// Create a context without a transaction to make DAO operations fail:
ctxNoTx := context.Background()

claims := jwt.MapClaims{
"email": "iris@example.com",
"name": "Iris Chang",
}

// Should not panic:
Expect(func() {
provisioner.EnsureUserExists(ctxNoTx, "iris", "tenant9", claims)
}).ToNot(Panic())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

This test never reaches the create-failure branch.

With ctxNoTx, EnsureUserExists returns from the List().Do(ctx) error path, so Create().Do(ctx) is never exercised. Please make the lookup succeed and only the create call fail if you want coverage for lines 130-140.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/database/dao/user_provisioner_test.go` around lines 312 - 324, The
EnsureUserExists failure-case test is using a context that fails too early in
the List path, so it never reaches the create-failure branch. Update the test in
user_provisioner_test.go around EnsureUserExists to use a context/mocks setup
where the lookup succeeds and only the DAO Create().Do call fails, so the
non-panicking behavior is exercised for the create error path.

Comment thread internal/database/dao/user_provisioner.go Outdated
Comment on lines +74 to +90
// Try to find the user by username using a filter
filter := fmt.Sprintf("this.spec.username=='%s'", username)
listResponse, err := p.usersDAO.List().
SetFilter(filter).
SetLimit(1).
Do(ctx)
if err != nil {
p.logger.InfoContext(ctx, "Failed to check if user exists (degraded mode - continuing without JIT provisioning)",
slog.String("username", username),
slog.Any("error", err),
)
return
}

// If user already exists, nothing to do
if listResponse.GetSize() > 0 {
p.logger.DebugContext(ctx, "User already exists, skipping JIT provisioning",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Scope the existence check to the same tenant you later persist.

The create path stores both username and tenant/organization, but Line 75 only checks username. Any existing row for the same username suppresses provisioning for other tenants, so first login can silently skip user creation in the wrong org.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/database/dao/user_provisioner.go` around lines 74 - 90, The
existence check in UserProvisioner.ProvisionUser currently only filters by
username, which can block provisioning across different tenants. Update the list
lookup in p.usersDAO.List() to include the same tenant/organization scope used
by the create path, using the existing username plus the tenant/org field from
the current request so only users in that tenant suppress JIT provisioning.

Comment on lines +75 to +79
filter := fmt.Sprintf("this.spec.username=='%s'", username)
listResponse, err := p.usersDAO.List().
SetFilter(filter).
SetLimit(1).
Do(ctx)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Don't splice raw JWT usernames into the filter expression.

Line 75 builds the lookup predicate from an untrusted claim. A username containing ' or filter operators can change the predicate or make it invalid before it reaches the DAO layer. Use an escaping/parameterization helper instead of string concatenation. As per path instructions, "SQL: parameterized queries only; no string concatenation" and "Validate at trust boundaries with allow-lists, not deny-lists".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/database/dao/user_provisioner.go` around lines 75 - 79, The user
lookup filter in user_provisioner’s provisioning flow is built by concatenating
the JWT-derived username directly into the predicate, which allows quote or
operator injection and can break the query. Update the lookup in the provisioner
method that calls usersDAO.List().SetFilter(...).SetLimit(1).Do(ctx) to use an
escaping or parameterized filter helper instead of fmt.Sprintf/string
concatenation, and validate the username at the trust boundary with an
allow-list before constructing the filter.

Source: Path instructions

Comment thread internal/database/dao/user_provisioner.go Outdated
@omer-vishlitzky

Copy link
Copy Markdown
Contributor

💀 CI Triage: broken_main | Category: BOOT

Root cause: osac-installer main references unmerged osac-operator commit 08991cc; image was never published to ghcr.io

Causal chain:

  • osac-installer commit 768174e updated osac-operator submodule to 08991ccbb (unmerged PR Bump docker/login-action from 3 to 4 #320)
  • values/vmaas-ci/values.yaml set operator.image.tag to sha-08991cc
  • vmaas-helm snapshot rebuilt after 21:13 UTC with broken reference
  • Boot step runs Helm upgrade with broken image tag
  • Pod osac-operator-68866ff5c6-6gcnq tries to pull ghcr.io/osac-project/osac-operator:sha-08991cc
  • Image pull fails: manifest unknown (image never published - PR builds don't push to ghcr.io)
  • Refresh phase times out waiting for osac-operator rollout
  • Boot step fails with ImagePullBackOff

Evidence:

step:e2e-vmaas-osac-project-cluster-tool-boot:

ERROR: parallel tasks failed: wait fulfillment: osac-operator: osac-operator-68866ff5c6-6gcnq: ImagePullBackOff: Back-off pulling image "ghcr.io/osac-project/osac-operator:sha-08991cc": ErrImagePull: initializing source docker://ghcr.io/osac-project/osac-operator:sha-08991cc: reading manifest sha-08991cc in ghcr.io/osac-project/osac-operator: manifest unknown

osac-logs/events.txt:

10s         Warning   Failed                 pod/osac-operator-68866ff5c6-6gcnq                     Failed to pull image "ghcr.io/osac-project/osac-operator:sha-08991cc": initializing source docker://ghcr.io/osac-project/osac-operator:sha-08991cc: reading manifest sha-08991cc in ghcr.io/osac-project/osac-operator: manifest unknown

osac-installer:values/vmaas-ci/values.yaml:8:

tag: sha-08991cc

osac-installer:commit:768174ea9e0ec49816717c859006abdb9727bfcf:

Sync image tags with submodule commits (committed 2026-06-25 21:13:27 UTC)

gh:osac-operator:pr:320:

PR #320 state=OPEN, mergedAt=null - commit 08991ccbb443e42f3a464c3e4b9339e396fbe221 never merged to main

gh:osac-operator:actions:

Build container image for 08991cc ran on event=pull_request (doesn't push to ghcr.io)

step:e2e-vmaas-osac-project-cluster-tool-boot:

CLUSTER_TOOL_FLAVOR_IMAGE: quay.io/rh-ee-ovishlit/cluster-flavors:vmaas-helm

differential:pr:779:success:build:2070247383421161472:

Passing run (22:30:29) used ghcr.io/osac-project/osac-operator:sha-8220502 (exists and works)

differential:pr:723:failure:build:2070264122477383680:

Different PR (#723) failed at 22:47:21 with identical error: osac-operator sha-08991cc manifest unknown

Suggestion: Revert osac-installer commit 768174e OR merge osac-operator PR #320 and manually trigger container image build workflow to publish sha-08991cc image to ghcr.io, then rebuild vmaas-helm snapshot


Prow job | Build 2070263999802380288 | 🤖 triagent | Cost: $1.3740

For deeper investigation, use the /osac-debug-e2e skill with this build ID.

@eliorerz

Copy link
Copy Markdown
Contributor

/retest

@eliorerz

Copy link
Copy Markdown
Contributor

/test ci/prow/e2e-vmaas

Comment thread internal/auth/grpc_authz_interceptor.go Outdated
//go:generate mockgen -destination=user_provisioner_mock.go -package=auth . UserProvisioner
type UserProvisioner interface {
// EnsureUserExists checks if a user exists and creates them if not. This is best-effort and should not return
// errors - failures are logged but don't block authentication.

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.

Why the best-effort approach? Can't we return an error when the provisioning fails?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Makes sense, returning an error now. Thanks!

Comment thread internal/auth/grpc_authz_interceptor.go Outdated
type UserProvisioner interface {
// EnsureUserExists checks if a user exists and creates them if not. This is best-effort and should not return
// errors - failures are logged but don't block authentication.
EnsureUserExists(ctx context.Context, username, tenant string, claims jwt.MapClaims)

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.

If this is a provisioner I'd say the method should be named Provision.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Gotcha, thanks Juan! Updated to Provision method

Comment thread internal/auth/grpc_authz_interceptor.go Outdated
// makes their first request, the interceptor will automatically create a user record in the database if one doesn't
// exist. This is optional - if not set, JIT provisioning is disabled.
func (b *GrpcAuthzInterceptorBuilder) SetUserProvisioner(value UserProvisioner) *GrpcAuthzInterceptorBuilder {
b.userProvisioner = value

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.

In setters that pass an interface it is good practice to use reflection.NormalizeNil to avoid interfaces that aren't nil but have values that are nil.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Gotcha, thank you for letting me know! Updated to use reflection.NormalizeNil in the setter.

Comment thread internal/auth/grpc_authz_interceptor.go Outdated
}

// ensureUserExists performs just-in-time user provisioning for JWT-authenticated users by delegating to the
// configured UserProvisioner. This function never returns an error - failures are logged but don't block the request.

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.

I think it is better to fail if the user can't be created. Why do you think it is better to just continue?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I guess to allow the workflow to continue, but you're right it's better to ensure that the user does exist in our system. Updated to return an error. Thank you!

Comment thread internal/auth/grpc_authz_interceptor.go Outdated
// configured UserProvisioner. This function never returns an error - failures are logged but don't block the request.
func (i *GrpcAuthzInterceptor) ensureUserExists(ctx context.Context, subject *Subject, token *jwt.Token) {
// Skip JIT provisioning if no user provisioner is configured
if i.userProvisioner == 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.

This ^ is why you want to use reflection.NormalizeNil in the builder. I you don't then this may be true and still you will get a nil pointer exception when trying to use the value.

Comment thread internal/auth/grpc_authz_interceptor.go Outdated
if s, ok := orgSlice[0].(string); ok {
tenant = s
}
}

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.

I think it would be better to use the Tenants field from the subject. That is the set of tenants that the user can see. If it is exactly one tenant then that is the tenant that you have to use. If empty, more than one, or infinite, then you know this isn't a regular tenant user and you cannot a user record.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Makes sense, thank you for pointing that out! I've modified it to only store users with a finite set with exactly one tenant.

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.

I understand that you are putting the implementation of the provisioner in the dao package to avoid circular dependencies. But it doesn't feel right. I'd prefer to keep logic out of the database package, if possible. Can we move this implementation to a new provisioners package, for example?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That makes sense, I've moved it out. Thank you!

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.

Looks like the provisioning logic is mostly independent of the rest of the logic of this interceptor: it only happens at the very end, and it only uses the information from the result of this interceptor. Would it make sense to have a separate interceptor for this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That makes sense! I created a separate interceptor as suggested. How does it look? Thank you for your feedback.

})

// createUsersTableSQL creates the `users` table for testing purposes.
const createUsersTableSQL = `

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.

Try to not do this. If the users table is going to be needed, then it should be created by a migration.

@CrystalChun
CrystalChun marked this pull request as draft June 26, 2026 14:31
@CrystalChun
CrystalChun force-pushed the jit branch 7 times, most recently from 3928e2a to 2cd95a5 Compare June 26, 2026 15:28
@CrystalChun

Copy link
Copy Markdown
Contributor Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@CrystalChun
CrystalChun marked this pull request as ready for review June 26, 2026 15:56
@openshift-ci
openshift-ci Bot requested review from akshaynadkarni and trewest June 26, 2026 15:56
@CrystalChun
CrystalChun requested a review from jhernand June 26, 2026 15:57

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
internal/auth/grpc_jit_provisioning_interceptor.go (1)

104-109: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

SubjectFromContext panics — the nil guard is unreachable.

SubjectFromContext panics when no subject is in context (see auth_context.go), so the if subject == nil bypass never fires. Any request that reaches this interceptor without a subject (e.g. auth-exempt methods like health/reflection) panics and gets recovered into an Internal error instead of skipping provisioning. Use a non-panicking lookup.

🛠️ Suggested fix
-	// Get subject from context (set by authz interceptor)
-	subject := SubjectFromContext(ctx)
-	if subject == nil {
+	// Get subject from context (set by authz interceptor)
+	subject, ok := ctx.Value(subjectContextKey).(*Subject)
+	if !ok || subject == nil {
 		// No subject means anonymous or unauthenticated request - skip provisioning
 		return nil
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/auth/grpc_jit_provisioning_interceptor.go` around lines 104 - 109,
`SubjectFromContext` in `grpc_jit_provisioning_interceptor.go` is the wrong
lookup here because it panics when no subject exists, so the `nil` check never
protects auth-exempt calls. Update the interceptor to use a non-panicking
context lookup for the subject, then keep the existing skip-provisioning path
when no subject is present. Make sure the fix is applied in the provisioning
interceptor flow where `SubjectFromContext` is currently used.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@internal/auth/grpc_jit_provisioning_interceptor.go`:
- Around line 104-109: `SubjectFromContext` in
`grpc_jit_provisioning_interceptor.go` is the wrong lookup here because it
panics when no subject exists, so the `nil` check never protects auth-exempt
calls. Update the interceptor to use a non-panicking context lookup for the
subject, then keep the existing skip-provisioning path when no subject is
present. Make sure the fix is applied in the provisioning interceptor flow where
`SubjectFromContext` is currently used.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: osac-project/coderabbit/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: 5979272c-6ae5-4492-9440-799dd35d7d28

📥 Commits

Reviewing files that changed from the base of the PR and between 6d288a6 and ccefd8e.

📒 Files selected for processing (8)
  • internal/auth/grpc_authz_interceptor.go
  • internal/auth/grpc_jit_provisioning_interceptor.go
  • internal/auth/grpc_jit_provisioning_interceptor_test.go
  • internal/cmd/service/start/grpcserver/start_grpc_server_cmd.go
  • internal/idp/keycloak/client_test.go
  • internal/provisioners/user_provisioner.go
  • internal/provisioners/user_provisioner_test.go
  • internal/rendering/table_renderer.go
💤 Files with no reviewable changes (5)
  • internal/idp/keycloak/client_test.go
  • internal/provisioners/user_provisioner.go
  • internal/cmd/service/start/grpcserver/start_grpc_server_cmd.go
  • internal/provisioners/user_provisioner_test.go
  • internal/rendering/table_renderer.go

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

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

⚠️ Outside diff range comments (1)
internal/rendering/table_renderer.go (1)

253-266: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Distinguish "not found" from other read failures.

loadTable now collapses every fs.ReadFile error into a generic "not found" case, logged only at Debug and silently resolved via the default table. This masks genuinely unexpected failures (e.g. embed corruption, path issues) that previously surfaced as errors. Consider checking errors.Is(err, fs.ErrNotExist) and only swallowing that specific case, propagating other errors so Render's Warn-level fallback (and visibility) still applies to them.

🔧 Proposed fix
 	data, err := fs.ReadFile(tablesFS, path.Join("tables", file))
 	if err != nil {
-		// If the file doesn't exist, that's okay - we'll use the default table.
-		// This handles both fs.ErrNotExist and any path-related errors.
-		r.logger.Debug(
-			"Table definition not found, will use default",
-			slog.String("type", string(helper.FullName())),
-			slog.String("file", file),
-		)
-		return nil, nil
+		if errors.Is(err, fs.ErrNotExist) {
+			r.logger.Debug(
+				"Table definition not found, will use default",
+				slog.String("type", string(helper.FullName())),
+				slog.String("file", file),
+			)
+			return nil, nil
+		}
+		return nil, fmt.Errorf("failed to read table definition file %q: %w", file, err)
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/rendering/table_renderer.go` around lines 253 - 266,
`TableRenderer.loadTable` is swallowing every `fs.ReadFile` failure as if the
table file were missing, which hides real read errors. Update the error handling
after `fs.ReadFile(tablesFS, path.Join("tables", file))` to only treat
`fs.ErrNotExist` as the default-table case, and return any other error so
`Render` can surface it through its existing warning/fallback path. Keep the
existing debug log for the missing-file branch, but preserve unexpected errors
instead of converting them to nil.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/provisioners/user_provisioner.go`:
- Around line 61-96: The List()-then-Create() flow in user_provisioner.go is not
atomic and can race for concurrent first-logins. Update the user provisioning
logic in the CreateUser path to use a single idempotent write via
p.usersDAO.Create() (or an upsert equivalent), and treat an “already exists”
duplicate error as success instead of returning failure. Keep the existing check
around p.usersDAO.List()/Do(ctx) only if needed for a fast-path, but the
authoritative outcome should come from the create operation and the user should
be considered provisioned when the record already exists.

---

Outside diff comments:
In `@internal/rendering/table_renderer.go`:
- Around line 253-266: `TableRenderer.loadTable` is swallowing every
`fs.ReadFile` failure as if the table file were missing, which hides real read
errors. Update the error handling after `fs.ReadFile(tablesFS,
path.Join("tables", file))` to only treat `fs.ErrNotExist` as the default-table
case, and return any other error so `Render` can surface it through its existing
warning/fallback path. Keep the existing debug log for the missing-file branch,
but preserve unexpected errors instead of converting them to nil.
🪄 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: Repository: osac-project/coderabbit/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: 71d86eb3-925c-4bd8-9eec-d7aee71a5cec

📥 Commits

Reviewing files that changed from the base of the PR and between ccefd8e and fdf514c.

📒 Files selected for processing (8)
  • internal/auth/grpc_authz_interceptor.go
  • internal/auth/grpc_jit_provisioning_interceptor.go
  • internal/auth/grpc_jit_provisioning_interceptor_test.go
  • internal/cmd/service/start/grpcserver/start_grpc_server_cmd.go
  • internal/idp/keycloak/client_test.go
  • internal/provisioners/user_provisioner.go
  • internal/provisioners/user_provisioner_test.go
  • internal/rendering/table_renderer.go

Comment on lines +61 to +96
// Check if user exists
filter := fmt.Sprintf("this.spec.username==%q", username)
listResponse, err := p.usersDAO.List().
SetFilter(filter).
SetLimit(1).
Do(ctx)
if err != nil {
return fmt.Errorf("failed to check if user exists: %w", err)
}

// User already exists
if listResponse.GetSize() > 0 {
return nil
}

// Extract claims
email, _ := claims["email"].(string)

// Create user
user := privatev1.User_builder{
Metadata: privatev1.Metadata_builder{
Name: username,
Tenant: tenant,
}.Build(),
Spec: privatev1.UserSpec_builder{
Username: username,
Email: email,
Enabled: true,
}.Build(),
}.Build()

_, err = p.usersDAO.Create().
SetObject(user).
Do(ctx)
if err != nil {
return fmt.Errorf("failed to create user: %w", err)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Collapse the existence check and create into one idempotent write.

This List()-then-Create() flow races on first login. Two concurrent requests for the same user can both observe “missing”, then the loser gets a duplicate/create error and the interceptor rejects that request even though the user now exists. Treat already exists from Create() as success, or switch this path to an atomic upsert.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/provisioners/user_provisioner.go` around lines 61 - 96, The
List()-then-Create() flow in user_provisioner.go is not atomic and can race for
concurrent first-logins. Update the user provisioning logic in the CreateUser
path to use a single idempotent write via p.usersDAO.Create() (or an upsert
equivalent), and treat an “already exists” duplicate error as success instead of
returning failure. Keep the existing check around p.usersDAO.List()/Do(ctx) only
if needed for a fast-path, but the authoritative outcome should come from the
create operation and the user should be considered provisioned when the record
already exists.

When an IdP user first logs in, we should create the user
in our database for persistent records. This creates a new
interceptor to handle this case.

Assisted-by: Claude Code <noreply@anthropic.com>
Importing NormalizeNil into the util directory
to prevent an import cycle.

Assisted-by: Claude Code <noreply@anthropic.com>
Comment thread internal/util/nil.go

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

CC @jhernand there was an import cycle somehow

auth → reflection → config → auth

and the suggestion was to move NormalizeNil into a separate utility package. Is this ok or should I explore a different solution?

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.

I see. I tried to break the dependency between reflection and config in #820. But merge this and I will refactor it later.

@openshift-ci openshift-ci Bot added the lgtm label Jul 1, 2026
@openshift-ci

openshift-ci Bot commented Jul 1, 2026

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is APPROVED

This pull-request has been approved by: CrystalChun, jhernand

The full list of commands accepted by this bot can be found here.

The pull request process is described here

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@CrystalChun

Copy link
Copy Markdown
Contributor Author

/test e2e-vmaas

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

Triggered: E2E VMaaS Full Install

@openshift-merge-bot
openshift-merge-bot Bot merged commit 4496a65 into osac-project:main Jul 1, 2026
16 checks passed
@CrystalChun
CrystalChun deleted the jit branch July 10, 2026 20:08
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants