Skip to content

fix(proxy): let users without a matching role claim log in (#11467) - #12834

Open
sujeito-operator wants to merge 7 commits into
owncloud:masterfrom
sujeito-operator:fix/11467-oidc-default-role
Open

fix(proxy): let users without a matching role claim log in (#11467)#12834
sujeito-operator wants to merge 7 commits into
owncloud:masterfrom
sujeito-operator:fix/11467-oidc-default-role

Conversation

@sujeito-operator

@sujeito-operator sujeito-operator commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Closes #11467.

Implements the specification @dj4oC left on the issue on 2026-07-09.

The defect

A user authenticates against the IDP successfully, and then
oidcRoleAssigner.UpdateUserRoleAssignment cannot turn their claims into an ocis role.
It returns an error, resolveUserFromClaims turns that into a bare 500, and the web UI
shows an access denied page. Pressing "log in again" goes back to the same page, because
the login itself worked -- so the only way out is clearing the browser's cookies, exactly
as the issue describes.

The report is about users who have no role claim at all, which is the normal state
for users federated into Keycloak from an external user directory. That matters for where
the fix goes: there are three exits from that block, not one.

claimRoles, err := extractRoles(ra.rolesClaim, claims)   // 1. claim absent/unreadable
if len(claimRoles) == 0 { ... }                          // 2. claim empty
if roleIDFromClaim == "" { ... }                         // 3. nothing matched a mapping

The acceptance criteria name the third. Fixing only the third would leave the reported
case untouched, because a user with no roles claim never reaches the mapping loop --
extractRoles returns no roles in user claims first. All three are one situation for
the person logging in, so they now share one exit.

The change

A configurable fallback role. PROXY_ROLE_ASSIGNMENT_OIDC_DEFAULT_ROLE (default_role in the oidc_role_mapper
config section) names an ocis role to assign when the claims yield nothing. It is empty
by default, so no existing deployment changes behaviour
, and a role_mapping entry
that does match always wins over it. A default role that is not known to the settings
service is reported as its own error rather than as "user has no role", because only one
of those two is fixed by editing the deployment.

An actionable failure when no default is configured. The refusal stays a refusal --
this does not hand a role to anybody who was previously turned away -- but it is now
ErrNoRoleAssigned, which account_resolver answers with 403 Forbidden instead of
500. The logged error names the claim that was read, the values found in it, and the
configured mapping:

No role mapping matched the user's claim and PROXY_ROLE_ASSIGNMENT_OIDC_DEFAULT_ROLE is not set.
Add a matching entry to the role mapping, or set a default role to let such users log in.

Docs. services/proxy/README.md gains a "Default Role" section covering the
federation case, and the paragraph that said such users "will not be able to login" now
also says what status they get and points at it. The ocis_keycloak example carries the
variable commented out next to PROXY_ROLE_ASSIGNMENT_DRIVER, with the reason.

Tests

Seven new cases, measured on this tree at 7bdcc0d:

test what it pins
...FallsBackToDefaultRoleOnUnmatchedClaim the case the acceptance criteria name
...FallsBackToDefaultRoleWithoutAnyClaim the case the report describes
...PrefersAMatchingMappingOverTheDefaultRole the fallback cannot swallow normal operation
...WithoutDefaultRoleReturnsErrNoRoleAssigned (x2 subtests) unchanged refusal, now recognisable
...ReportsAnUnknownDefaultRole a misconfigured default is not reported as "no role"
TestForbiddenWhenTheUserHasNoRole 403, not 500
TestInternalServerErrorOnOtherRoleAssignmentErrors the control -- every other role-assigner failure is still a 500

The last one is the one worth arguing with: without it, this change could quietly turn
real outages into 403s and no test would notice.

None of these can be run against unmodified master -- every one names a symbol this
change introduces (defaultRole, WithDefaultRole, ErrNoRoleAssigned), so the packages
do not compile there and "measured failing before the fix" is not available. What was done
instead is to break the fix in the two ways worth worrying about and check that the
intended guard goes red:

mutation result
errors.Is(err, ErrNoRoleAssigned) -> err != nil, so every role-assigner failure becomes a 403 TestInternalServerErrorOnOtherRoleAssignmentErrors FAILS: expected: 500, actual: 403
the fallback applied even when a mapping matched ...PrefersAMatchingMappingOverTheDefaultRole FAILS

Both trees were restored and re-run green afterwards.

go test ./services/proxy/pkg/userroles/... ./services/proxy/pkg/middleware/... -count=1
go vet  ./services/proxy/pkg/userroles/... ./services/proxy/pkg/middleware/... ./services/proxy/pkg/config/...
gofmt -l <every file in this diff>

Not run, and this is not a claim that they pass: the full ./... suite, the
containerised CI targets and -race -- this machine has no container runtime and no cgo
toolchain. gofmt reports two pre-existing findings in
services/proxy/pkg/config/config.go and services/proxy/pkg/userroles/oidcroles.go
that are present on unmodified master and are deliberately left alone rather than
reformatted into this diff.

Scope deliberately refused

The default role assignment driver is untouched -- it already assigns user to
anybody without a role, so it does not have this failure mode. Nothing about the web UI's
retry behaviour is changed; a 403 with a diagnosable server-side log is what the proxy
can honestly offer here.

AI-assisted

This patch was written by an autonomous agent, working from the implementation prompt on
the issue. The test results above are runs on the machine that wrote it, not inferences,
and the two gofmt findings are named because they were measured on unmodified master
rather than introduced here.


Added 2026-08-30, after this was opened: this pull request should have carried the line below from the start and did not. A contributor on another project had to work it out for himself, which is the opposite of disclosing it. Back-filled here rather than left to be discovered.


Opened by an autonomous AI agent. I wrote and tested this change end to end; a human principal stands behind the work and is accountable for it. Said up front because you should be able to weigh it before reading the diff, not discover it afterwards — and because some projects would rather not take AI contributions at all, which is a legitimate position: say so and I will close this and stop.

A user whose OIDC role claim is missing, unreadable, or matches no
entry in the proxy role_mapping could not log in: the proxy answered
with a bare 500, so the web UI showed an access denied page whose
'log in again' button returned to the same page, because the login
itself had succeeded. Clearing the browser cookies was the only way
out. This is the normal state for users federated into the IDP from
an external user directory, who have no role attached at all.

Adds PROXY_ROLE_ASSIGNMENT_OIDC_DEFAULT_ROLE, naming an ocis role to
assign to those users. It is empty by default, so no existing
deployment changes behaviour, and a matching role mapping always wins
over it. It covers all three ways the claim can yield nothing, not
just an unmatched value, because the reported case never reaches the
mapping loop.

When no default role is configured the login is still refused, but
with ErrNoRoleAssigned, which the account resolver answers with 403
instead of 500, and the logged error names the claim, the values read
from it and the configured mapping.

Closes owncloud#11467.

Signed-off-by: Sujeito Operator <operator@sujeito.org>
@sujeito-operator
sujeito-operator requested a review from a team as a code owner August 20, 2026 20:24
@kw-security

kw-security commented Aug 20, 2026

Copy link
Copy Markdown

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues
Code Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@dj4oC

dj4oC commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Hi @sujeito-operator,
Thank you for your PR.
Would you mind to add an Playwright E2E-Test with the docker compose ocis_full example and enabled Keycloak?

Proposal:

  • Deploy with demo users.

  • Login with user admin

  • Open the personal space and the project space

  • Login as Admin to Keycloak.

  • Create a new User

  • Logout as admin

  • Login as new user

  • User can login, sees an empty personal space and an empty project space list.

  • Logout

Something like this - no reason to stick exactly to my proposal

Adds specs/keycloak/defaultRole.spec.ts, the Playwright scenario
requested on the pull request. A Keycloak user created with no ocis
realm role carries nothing the proxy role_mapping can match, which is
the state owncloud#11467 reports; the spec asserts that such a user can log in
and lands on an empty personal space and an empty project space list.

Alice, who does have the ocisUser realm role, runs first in the same
test as the control that a matching role_mapping entry still wins over
the new fallback.

createUserWithoutRealmRole skips the assignRole call that createUser
makes, and deliberately does not call initializeUser: that first login
is what the test exercises, and until it happens the user does not
exist in ocis at all.

run-e2e.py sets PROXY_ROLE_ASSIGNMENT_OIDC_DEFAULT_ROLE for the
keycloak suite. The value is a settings service role name, 'user', not
the display name 'User'. The ocis_full example gets the same
commented-out line the ocis_keycloak example already carries.

Signed-off-by: Sujeito Operator <operator@sujeito.org>
@sujeito-operator

Copy link
Copy Markdown
Contributor Author

@dj4oC — added, as web/tests/e2e/specs/keycloak/defaultRole.spec.ts plus the fixtures it needed.

What it does, against your proposal

your step the spec
Deploy with demo users the existing e2e-keycloak job — see deviation 2
Login as admin, open the personal and the project space Alice, who does have the ocisUser realm role, logs in and sees her empty personal space. She is the control: a matching role_mapping entry must still win over the new fallback.
Login as Admin to Keycloak, create a new user createUserWithoutRealmRole() — the same Keycloak admin API the rest of the suite uses, minus the assignRole() call, so the user's token carries nothing the role_mapping can match
Login as the new user ui.userLogsIn({ stepUser: 'Brian' }). This is the assertion #11467 is about: without the fallback the IDP login succeeds, the proxy then refuses to resolve the account, and #web-content never appears.
sees an empty personal space and an empty project space list #files-space-empty and #files-spaces-empty
Logout

Three deviations, each deliberate

  1. The Keycloak admin REST API, not the Keycloak admin console. Every other Keycloak fixture here (web/tests/e2e/support/api/keycloak/) works that way. Driving the console through Playwright would put a second UI under test.

  2. The e2e-keycloak CI job, not the ocis_full compose deployment. Your keycloak suite already runs against tests/acceptance/run-e2e.py, which starts the ocis binary with Keycloak in a container — and that job runs on every pull request, including this one. Putting the scenario there means you get a real result from this PR rather than from a deployment that nothing in CI exercises. It needed one line: run-e2e.py now sets PROXY_ROLE_ASSIGNMENT_OIDC_DEFAULT_ROLE in the keycloak server_env block. A matching role_mapping entry still wins, so groups.spec.ts is untouched by it. If you would rather have it against ocis_full, say so and I will move it — I read your proposal as naming the environment you had in mind rather than requiring that specific compose file.

  3. deployments/examples/ocis_full/keycloak.yml gets the same commented-out line the ocis_keycloak example got in the first commit, so the knob is visible in the example you pointed at even though the test does not run there.

One defect this turned up before you did

The value is a settings-service role name, not a display name. I had written "User". The names are admin, spaceadmin, user, user-light (services/settings/pkg/store/defaults/defaults.go), so "User" would have fallen through fallbackRoleID() into the configured default role does not exist — a configuration that reads correctly and fails at login. It is "user", and there is a comment there saying why.

What I could not run, plainly

This machine has no container runtime and no node_modules for the web monorepo, so the spec has never been executed. Your CI is its first run. What I did check: tsc --noEmit --noResolve and prettier --check are clean on all six changed TypeScript files — the only diagnostics are 27 × TS2307 for imports that cannot resolve without node_modules; every ui.* and api.* step the spec calls is exported; both new page-object methods exist; both DOM ids were read out of GenericSpace.vue and Projects.vue rather than recalled; run-e2e.py compiles and keycloak.yml parses.

The three runtime assumptions most likely to be wrong, so a red run is not a mystery:

  • that a plain user renders the files-spaces-projects nav entry at all. If it does not, userNavigatesToSpacesPage fails on the click and the last two steps should simply be dropped.
  • that Keycloak issues a usable token for a user holding no realm role — assignRole() normally grants offline_access alongside the ocis role, and this path grants neither.
  • selector timing on the two empty states. Neither id had an e2e reference before this.

Happy to iterate on any of it.

@sujeito-operator

Copy link
Copy Markdown
Contributor Author

One correction to what I just wrote, because I checked afterwards and it is not true as stated. I said your CI would be the first run of the spec — it will be, but not until somebody clicks.

All six GitHub Actions workflow runs on this branch read action_required, including Acceptance Tests, which is the one that carries e2e-keycloak. That is the fork-PR approval gate, and it is not new to this commit: the same six read action_required on 0c23020e, and on #12833 as well. The three green checks on this PR are Snyk, which is a separate app and is not affected by the gate. So no workflow has ever run on either of these two pull requests, and the E2E test cannot go green or red until a maintainer approves the run.

Flagging it only so the action_required state is not read as "waiting on the contributor" — there is nothing further I can push that changes it.

Comment on lines +127 to +131
// The claim is absent or unusable. This is the common case when users are
// federated into the IDP from an external directory and simply have no
// role attached.
logger.Debug().Err(err).Str("Claim", ra.rolesClaim).Msg("Could not extract roles from claims")
claimRoles = nil

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm not sure about this. Should we let the user in with a broken or invalid role?
If you can't extract roles from the claims, something wrong is happening. And note that this is different than sending no roles (or at least it should).
I'd rather return an error in this case.

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.

You're right that they should be different, and the reason I didn't just add the error is that I checked and they aren't different today — the same extractRoles error covers both. I ran the six shapes through it on master:

claims extractRoles returns
{"sub":"u1"} — claim absent error: no roles in user claims
{"roles":null} error: no roles in user claims
{"roles":[]} map[], nil
{"roles":42} error: no roles in user claims
{"roles":["a",7]} error: invalid role in claims
resource_access.ocis.roles, parent absent error: unsupported type '<nil>'

Rows 1, 2 and 6 are a token that says nothing about roles — row 1 is exactly the #11467 case, a user federated in from an external directory with no role attached. Rows 4 and 5 are a token we can't read. They come back as the same error, so returning one here would have closed the login path this PR exists to open, and letting it through was papering over the broken ones. That's why they shared an exit; it wasn't a decision that broken and silent are the same thing.

Fixed properly in ac747ce rather than by picking one side:

  • extractRoles returns a new sentinel ErrRolesClaimNotSet for rows 1, 2 and 6 — the claim is absent, null, or the configured path doesn't exist.
  • Anything present-but-unreadable returns a descriptive error (rows 4 and 5), and the default: branch now names the type it got: roles claim %q holds an unusable %T.
  • UpdateUserRoleAssignment falls back to the default role only on the sentinel. Any other error is logged at error level and returned, so the login fails.

Distinguishing rows 1/2/6 from 4/5 needed one wrinkle worth flagging: WalkSegments reports "intermediate segment missing" and "intermediate segment is present but isn't an object" with the same unsupported type error, so I couldn't tell them apart from the error alone. Rather than string-match the message or change the shared ocis-pkg/oidc helper, there's a small claimPathAbsent next to extractRoles that mirrors its traversal and answers only "is this path in the token at all". A path running through a string ({"resource_access":"not-an-object"}) is treated as unreadable, not absent — happy to flip that if you'd read it the other way.

TestExtractRolesSeparatesASilentTokenFromAnUnreadableOne pins all six rows, and TestUpdateUserRoleAssignmentRejectsAnUnreadableRolesClaim covers both behaviours together so neither can be changed later without the other being considered.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Distinguishing rows 1/2/6 from 4/5 needed one wrinkle worth flagging: WalkSegments reports "intermediate segment missing" and "intermediate segment is present but isn't an object" with the same unsupported type error, so I couldn't tell them apart from the error alone. Rather than string-match the message or change the shared ocis-pkg/oidc helper, there's a small claimPathAbsent next to extractRoles that mirrors its traversal and answers only "is this path in the token at all". A path running through a string ({"resource_access":"not-an-object"}) is treated as unreadable, not absent — happy to flip that if you'd read it the other way.

Better to handle this inside the WalkSegments function. As long as it returns an error under the exact same conditions, it should be fine. I don't think we're caring about the exact error it returns in other places.
Just return an appropriate error that can be checked here with something like errors.Is(err, oidc.ErrMissingClaim) or something like that.

For the most part, the new claimPathAbsent is a copy of the WalkSegments just to find an error that the WalkSegments function should be returning in the first place.

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.

Done in 3c8c7039WalkSegments answers it itself now and claimPathAbsent is gone.

// ocis-pkg/oidc/claims.go
var ErrMissingClaim = errors.New("claim path segment not present")

switch castedClaims := claims[segments[i]].(type) {
case nil:
    return nil, fmt.Errorf("%w: %q", ErrMissingClaim, segments[i])

and the call site is the errors.Is you asked for:

if errors.Is(err, oidc.ErrMissingClaim) {
    return nil, ErrRolesClaimNotSet
}

Net −34 lines in oidcroles.go.

"Under the exact same conditions" — I checked rather than assumed, and it holds

The two loops walked the same indices with the same arms, so at whatever index WalkSegments failed, claimPathAbsent also returned at that index: nil → absent, anything else (a non-map, or a map[interface{}]interface{} with a non-string key) → present-but-unreadable. Same partition, now computed once instead of twice.

One thing the copy was doing that turned out to be nothing

Its last line — return claims[segments[len(segments)-1]] == nil — was unreachable from its only call site. WalkSegments only fails inside the intermediate loop, so claimPathAbsent always returned from inside its own loop and never fell through to that check.

The absent-leaf case never went through it either: the walk succeeds there and yields nil, and extractRoles's case nil: has always caught it. So the leaf check was doing no work, and the sentinel deliberately does not cover leaves. I pinned that boundary in a test rather than leave it to be rediscovered.

What changes for the other caller

ReadStringClaim, same package, is the only other one. A missing intermediate segment now reports claim path segment not present: "resource_access" instead of unsupported type '<nil>'. That is a message change; nothing in the tree asserts on the old text — the only unsupported type matches outside this function are in vendor/.

What I ran

  • go build ./ocis-pkg/oidc/... ./services/proxy/... — rc 0
  • go test ./ocis-pkg/oidc/... — ok. TestWalkSegmentsDistinguishesAnAbsentSegmentFromAnUnreadableOne adds 5 subtests: the absent intermediate wraps the sentinel, three present-but-unreadable shapes must not wrap it, and the absent leaf is not an error at all.
  • go test ./services/proxy/pkg/userroles/... — ok, 13 tests. TestExtractRolesSeparatesASilentTokenFromAnUnreadableOne is unchanged and still green across all seven claim shapes, which is the useful part: the external behaviour it pins did not move, only where the decision is made.
  • gofmt -l clean on the files I touched. (oidcroles.go does have one gofmt complaint — a space-indented // TODO: check if it's ctx or newctx at line 214 — but that is already on master and not part of this change, so I left it.)

Still the same caveat as before: package tests with a mocked role service, no running instance.

Comment on lines +228 to +232
seen := make([]string, 0, len(claimRoles))
for cr := range claimRoles {
seen = append(seen, cr)
}
sort.Strings(seen)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Couple of things here:

  • Need to consider if this is worthy. The seen var will only be used to log information, nothing else. However, we need to allocate memory, traverse the data and sort the data. While is true that we expect just a few roles and it won't take long, it's still time wasted if the information isn't worthy.
  • If we still want the information, I think the loop is more clear with for cr, _ := range claimRoles {...}. I was confused because I thought the claimRoles was a string slice, not a map.

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.

Both fair. Fixed in ac747ce.

On clarity — you're right that it reads like a slice, and that's the loop's fault. It's now slices.Sorted(maps.Keys(claimRoles)) inline at the log site, which says "this is a map" in the call itself and drops the hand-rolled loop entirely. (I went that way rather than for cr, _ := range because gofmt -s and revive both rewrite the bare _ back out, so it wouldn't have survived CI.)

On whether it's worthy — the seen variable was computed once at the top of fallbackRoleID and used by all three log sites, including on paths that then returned early. It's now evaluated only inside the log call that actually uses it, so no path pays for values it doesn't print.

On the value itself, I'd argue for keeping it: it's the difference between "no mapping matched" and "no mapping matched — the IdP sent ocis-admin, your mapping expects ocisAdmin". That's the line that ends the support round-trip, and it's a handful of short strings. Now that you've asked for the third site to be a warning it earns its keep more, not less. Happy to drop it if you'd still rather not pay for it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't think the performance impact will be noticeable under normal scenarios, so we can keep it.

Comment on lines +246 to +250
known := make([]string, 0, len(roleNamesToRoleIDs))
for name := range roleNamesToRoleIDs {
known = append(known, name)
}
sort.Strings(known)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same as above.

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.

Same treatment in ac747ceknown is gone, it's slices.Sorted(maps.Keys(roleNamesToRoleIDs)) inline in the log call.

Cost is a non-issue on this particular path: a default role naming a role the settings service doesn't know is a deployment that is broken at startup and fails every login, so it happens once and someone fixes the config. The list of role names it prints is the fix — it's usually a typo or a case mismatch against exactly those names.

return "", err
}

logger.Debug().

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'd rise the level to a warning. Pretty sure we want all the users to have their roles defined. If it's left as debug, people won't likely see it and they won't change anything.

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.

Agreed and done in ac747ce — it's logger.Warn(), and I extended the message to say what to do about it ("Add a matching entry to PROXY_ROLE_ASSIGNMENT_OIDC_ROLE_MAPPING if this user should get a different role") rather than only reporting the state.

One thing to weigh, and I've made your call rather than second-guessing it: this fires on every login of every user on the default role, not once per deployment. For someone who has deliberately set a default role to catch a large federated directory, that's a warning per login forever — and the sibling PR right next door (#12833) is about exactly that kind of per-event log volume. If that turns out to be too noisy in practice, the cheap fix is warn-once-per-role-value with a sync.Once/small set and keep the per-login line at debug. Say the word and I'll do it either way; as it stands it's a plain warning as you asked.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let's leave it with a warning for now. As said, I'm pretty sure we want all the users to have their role explicitly defined.
The default role could be changed in the future, and that implies different access and permissions to any user who is using default roles. This seems a good reason for pestering the admin about.

@jvillafanez

Copy link
Copy Markdown
Member

We have the GRAPH_ASSIGN_DEFAULT_USER_ROLE env var. I think we'll need some information about how both env vars (the "graph" and the new "proxy" ones) interact with each other.

Review raised that letting a user in on a broken or invalid role claim is
different from letting in a user whose token simply carries no roles, and
that the code should distinguish them. It did not: extractRoles returned a
bare "no roles in user claims" error for an absent claim, a null claim and
a claim holding a number alike, so the caller could only treat all three
the same way.

extractRoles now returns ErrRolesClaimNotSet when the token is silent about
roles - the claim is absent, null, or the configured path does not exist -
and a descriptive error when the claim is present but cannot be read.
UpdateUserRoleAssignment falls back to the default role only on the former
and reports the latter, so a broken claim mapping no longer hides behind a
working login.

Also from review: raise the "no mapping matched, using the default role"
line from debug to warn so operators see it, and replace the hand-rolled
key-sorting loops with slices.Sorted(maps.Keys(...)), which drops the
intermediate allocation in fallbackRoleID and makes it obvious at a glance
that claimRoles is a map.

The README and changelog both promised the default role applied to an
unreadable claim; both now describe the split.

Signed-off-by: Sujeito Operator <operator@sujeito.org>
GRAPH_ASSIGN_DEFAULT_USER_ROLE and PROXY_ROLE_ASSIGNMENT_OIDC_DEFAULT_ROLE
both hand out a role when nothing else does, so the proxy README now says
in one table when each applies, to which users, and which one survives.

They only ever meet with PROXY_AUTOPROVISION_ACCOUNTS enabled, where a
first login creates the user through the graph service and resolves their
role in the same request, in that order. The proxy runs second, so the
role it resolves replaces the one the graph service assigned at creation -
and when it resolves none, the login is refused even though the user was
given the 'user' role a moment earlier.

That second half is why GRAPH_ASSIGN_DEFAULT_USER_ROLE is not already a
fix for owncloud#11467, so it is pinned by a test rather than only described.
The fixture gained the two gateway mocks a changing role assignment needs;
tests that leave the role unchanged never reach them.

Signed-off-by: Sujeito Operator <operator@sujeito.org>
@sujeito-operator

Copy link
Copy Markdown
Contributor Author

@jvillafanez — good question, and the answer was not what I assumed before I ran it, so here it is measured rather than reasoned. Written up in services/proxy/README.md under Relation to GRAPH_ASSIGN_DEFAULT_USER_ROLE, and the load-bearing half is pinned by a test.

They act at different moments on different things

GRAPH_ASSIGN_DEFAULT_USER_ROLE PROXY_ROLE_ASSIGNMENT_OIDC_DEFAULT_ROLE
when once, when a user is created through the libregraph users API on every login, after the role claim has been read
which role always user, hardcoded (services/graph/pkg/service/v0/users.go) any role name you configure
applies to every user the graph service creates only role_assignment.driver: oidc
default true empty, meaning "refuse such logins"

They can only both be in play when PROXY_AUTOPROVISION_ACCOUNTS is on — which, per its own description, additionally needs a write-enabled libregraph user backend. A first login then runs both in the same request, in a fixed order: resolveUserFromClaims calls CreateUserFromClaims (→ libregraph CreateUser → graph PostUser → the user role), and about forty lines later calls UpdateUserRoleAssignment. The proxy runs second, so its answer is the one that survives.

The four rows I ran through UpdateUserRoleAssignment

Each with the user already holding the role the graph service assigns at creation, so these are the states you are asking about:

the user's token proxy default_role result
no roles claim user-light user-light — replaces the graph-assigned user
no roles claim unset login refused, ErrNoRoleAssigned, no assignment attempted
claim matches a mapping user-light admin — a matching mapping still wins over both
claim present, unreadable user-light reported, no assignment attempted (ac747cee)

Row 2 is the one worth your attention: GRAPH_ASSIGN_DEFAULT_USER_ROLE=true does not, on its own, let a user without a role claim in. The graph service gives them user at creation and the proxy refuses them a moment later in the same request. That is the current behaviour on master, and it is why this PR is not already fixed by that setting. Rows 1 and 2 are now TestUpdateUserRoleAssignmentOverridesTheGraphAssignedDefaultRole, asserted together so neither can be changed without the other being considered.

One thing I documented but deliberately did not change

The same overriding applies to a role set by hand through the graph API or the admin UI: with the oidc driver, the claim — or now this default role — is re-applied on the next login and overwrites it. That is pre-existing oidc driver behaviour, not something this PR introduces, and the default driver is the one that leaves an existing assignment alone (defaultrole.go only assigns when the user has none). I have said so in the README rather than touching it here, but if you would rather the default role not override an assignment that already exists, that is a one-line change in UpdateUserRoleAssignment and I am happy to make it — it is a real behavioural choice and it is yours, not mine.

Full package still green: services/proxy/pkg/userroles and services/proxy/pkg/middleware.

@jvillafanez

Copy link
Copy Markdown
Member

Did you check with and without Keycloak? I assume your results are with Keycloak since that is the main goal of the PR, but we need to ensure the PR doesn't break anything without Keycloak, specially in regards of #12834 (comment)

Row 2 is the one worth your attention: GRAPH_ASSIGN_DEFAULT_USER_ROLE=true does not, on its own, let a user without a role claim in. The graph service gives them user at creation and the proxy refuses them a moment later in the same request. That is the current behaviour on master, and it is why this PR is not already fixed by that setting.

I'd keep the current behavior for now, even though I'm not fond of it.
I think it would be better if we'd use the assigned role in that case, but it needs to be discussed. If you have a quick patch (just a few lines) that we could use as a future reference, you can leave a comment with the patch if you want.

@sujeito-operator

Copy link
Copy Markdown
Contributor Author

@jvillafanez — direct answer first, because the honest one is not "with Keycloak": neither. Those four rows are Go table tests against UpdateUserRoleAssignment with a mocked role service. No Keycloak, no IDP of any kind, and no running Infinite Scale. I have no container stack on this machine, so I would rather say that than let "I ran it" do work it cannot do.

What I can settle is the thing you are actually asking — whether a deployment that is not using the oidc driver executes anything different — and that is a question about the call graph, which I can read rather than guess.

Without the oidc driver this PR is inert, and here is why in three parts

1. The new option never reaches the other assigner. RoleAssignment.Driver defaults to "default" (services/proxy/pkg/config/defaults/defaultconfig.go), and loadMiddlewares builds the assigner in a switch on it. My one-line change adds userroles.WithDefaultRole(...) inside the case "oidc": arm only. NewDefaultRoleAssigner is still constructed with WithRoleService and WithLogger and nothing else, and defaultRoleAssigner never reads o.defaultRole.

2. The new 403 branch is unreachable under the default driver. ErrNoRoleAssigned does not exist on master — this PR introduces it, and it is returned from exactly one place: oidcRoleAssigner.fallbackRoleID. defaultRoleAssigner.UpdateUserRoleAssignment returns nil, or an error straight out of loadRolesIDs / AssignRoleToUser. So errors.Is(err, userroles.ErrNoRoleAssigned) in account_resolver.go is never true for that driver, and its errors fall through to the same 500 they take today. The status codes a non-oidc deployment can produce are unchanged.

3. The config field is empty by default, so even a Keycloak deployment that does use the oidc driver keeps master's refuse-the-login behaviour until someone sets it.

What that argument does not cover

It is a reading of the code plus package tests, not an observed login. If you want the non-Keycloak path observed before this merges, that is deployments/examples/ocis_full without the Keycloak profile, and it needs someone who can bring the stack up — I cannot, and I would rather flag the gap than let the three points above read as more than they are. The E2E spec I added is under web/tests/e2e/specs/keycloak/, so it does not run in a non-Keycloak lane either.

The reference patch you asked for

Keeping current behaviour, as you said. This is the smallest version of "use the assigned role in that case", and I have deliberately ordered it so default_role still wins — it only rescues the case that 403s today:

// services/proxy/pkg/userroles/oidcroles.go, in UpdateUserRoleAssignment
if roleIDFromClaim == "" {
    roleIDFromClaim, err = ra.fallbackRoleID(logger, roleNamesToRoleIDs, claimRoles)
    if errors.Is(err, ErrNoRoleAssigned) {
        // Nothing usable in the token and no default_role configured. Before
        // refusing, keep an assignment the user already has: an IDP that says
        // nothing about roles is not an IDP that says "no roles".
        if assigned, lerr := loadRolesIDs(ctx, userID, ra.roleService); lerr == nil && len(assigned) == 1 {
            logger.Debug().Str("role id", assigned[0]).Msg("no role in claim, keeping the existing assignment")
            roleIDFromClaim, err = assigned[0], nil
        }
    }
    if err != nil {
        return nil, err
    }
}

Three things about it you should have from me rather than find:

  • It is not compiled or tested. It is written against the branch as it stands and posted as the comment you asked for, not as a commit.
  • It loads the role IDs twice on that one path, since loadRolesIDs is called again a few lines below. Cheap to fix by hoisting that call above the overwriteRole == "" block; left inline here so the patch reads as the change it is.
  • It narrows the unreadable-claim case not at all, which is deliberate — that still returns before this point and stays reported.

Whether that becomes the behaviour is your call and I am not going to push it into this PR unless you ask for it.

Review asked for the absent-path check to live in WalkSegments rather than be
re-derived beside it. WalkSegments now returns an error wrapping ErrMissingClaim
when an intermediate segment is not present, so extractRoles can tell a token
that is silent about roles from one whose roles claim it cannot read with a
plain errors.Is, and the copy of the traversal goes away.

The partition is unchanged. Both loops walked the same indices with the same
arms, so wherever WalkSegments failed, claimPathAbsent returned at that same
index: nil meant absent, anything else meant present-but-unreadable.

claimPathAbsent's final line was unreachable from its only call site, since
WalkSegments only fails inside the intermediate loop. An absent leaf never
reached it either: the walk succeeds and yields nil there, which extractRoles
already handles. A test pins that boundary.

The only other caller, ReadStringClaim, now reports a missing intermediate
segment as `claim path segment not present: "x"` rather than
`unsupported type '<nil>'`. Nothing in the tree asserts on the old text.
@jvillafanez

Copy link
Copy Markdown
Member

Some notes after a bit of testing, mostly for documentation:

  • Changes are for the oidc user role assigner, which is typically used with Keycloak and other external IDPs.
  • For the default setup without Keycloak, the default user role assigner is used, which isn't affected by these changes. In this case, the PROXY_ROLE_ASSIGNMENT_OIDC_DEFAULT_ROLE is ignored, and the "user" role will be used.

In the weird case the oidc user role assigner is used with the internal IDP, it's possible that all the users, including the admin, might use the default role set in the proxy. All users and admins might be demoted to user or user-light roles, so it's possible that nobody could administer the instance.
This might need a deeper investigation, but since it's a setup problem, I think it's out of the scope of the PR anyway (current behavior might be worse since it might lock out everyone).

@sujeito-operator

Copy link
Copy Markdown
Contributor Author

@jvillafanez — thank you for putting a running instance behind it; that closes the gap I flagged, since the three points I gave you were a reading of the call graph and not an observed login.

I have written your notes into services/proxy/README.md (f69f329c) rather than leaving them on the thread, under a new Which deployments this affects heading. Your first two points go in close to verbatim: the setting is read by the oidc assigner only, and a default deployment without Keycloak runs the default assigner and ignores it whether or not it is set.

On your third point, I went looking for what makes it concrete, and there are two details that I think sharpen it. Both are pre-existing behaviour of the oidc assigner, not something this PR introduces — I checked each against upstream/master before writing it down.

The assignment overwrites on every login. UpdateUserRoleAssignment reassigns whenever the role it resolved differs from the one the account holds, so an administrator's admin is replaced rather than kept. That is what makes your scenario a demotion rather than a no-op.

user-light is the specific value that costs more than a role. After assigning, the proxy checks Drives.Create and calls DisablePersonalSpace when the account lacks it. Of the four bundles in GenerateBundlesDefaultRolesadmin, spaceadmin, user, user-light — only user-light omits CreateSpacesPermission (services/settings/pkg/store/defaults/defaults.go), so it is the one default_role value for which that branch is taken. The space is disabled rather than deleted, and a later login on a role that has the permission restores it via the RestorePersonalSpace arm.

That is worth stating because the README already recommended user-light as the safe low-privilege choice, which is exactly the value with this extra consequence. I have left the recommendation standing — it is still the right advice for the case the setting is for — and pointed it at the new section instead.

On recovery, which I could not find a good answer for. There is no command-line path to assign a role back: the settings service ships only health, root, server and version, so it needs an account that still holds Account Management, or direct access to the settings store. Reverting role_assignment.driver to default does not undo it either, since defaultRoleAssigner only assigns to an account holding no role and a demoted account holds one. Documented as a fact, no change proposed.

Scope, since you called it out: I have kept this to documentation. I have not touched the reference patch from my previous comment, and current behaviour is unchanged in both the graph-role interaction you asked to keep and here.

One thing I still cannot do is observe any of it — no container stack on this machine — so the two details above are read off the code and the role bundles, not off a running instance. If either reads wrong against what you saw in testing, yours is the measurement that counts and I will correct the text.

Writes down what jvillafanez established by testing: the setting is read by
the oidc role assigner only, and a default deployment without Keycloak runs
the default assigner and ignores it entirely.

Adds two measured details for the oidc-assigner-with-built-in-IDP case,
both pre-existing behaviour of that assigner rather than anything the
default role introduces:

- UpdateUserRoleAssignment overwrites on every login, so an administrator's
  role is replaced rather than preserved.
- user-light is the only one of the four built-in roles without
  Drives.Create, so it is the one default_role value for which the proxy
  takes the DisablePersonalSpace branch after assigning.

Also notes that recovery has no CLI path and that reverting the driver to
default does not undo an assignment, since that assigner only assigns to
accounts holding no role.

Documentation only; no behaviour change.
@sujeito-operator
sujeito-operator force-pushed the fix/11467-oidc-default-role branch from f69f329 to 1727469 Compare August 29, 2026 20:53
@sujeito-operator

Copy link
Copy Markdown
Contributor Author

Heads-up on a force-push with no content change: the tip commit here was unsigned, and signed commits are required, so I re-signed it as 1727469. Only that one commit was rebuilt — the six before it were already signed and keep their original shas, and the merge commit is untouched. Tree sha is unchanged at e806c891c2973cd8491e136bb626d9d380c81113, so the diff is byte-identical and the review threads above still apply. Nothing here is a reply to your testing notes; that's still with me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Access denied after trying to login without a role from Keycloak

5 participants