Skip to content

feat(server): generic external auth callout - #4055

Draft
sansmoraxz wants to merge 4 commits into
apache:masterfrom
sansmoraxz:feat/ext-auth
Draft

sansmoraxz wants to merge 4 commits into
apache:masterfrom
sansmoraxz:feat/ext-auth

Conversation

@sansmoraxz

@sansmoraxz sansmoraxz commented Sep 4, 2026

Copy link
Copy Markdown

Which issue does this PR address?

Closes #3929

Rationale

Iggy supports only built-in credentials and HTTP-only JWT verification. There is no mechanism for an external service to authenticate a client and return its effective permissions, which blocks integration with centralized identity/policy systems (like Azure AD) or automated machine fleets.

What changed?

Login attempts on all transports (TCP, QUIC, WebSocket, HTTP) can forwarded to a configured HTTP endpoint. The service authenticates the client and returns one of three decisions: map to an existing Iggy user, grant a session-scoped identity with explicit permissions, or deny. Session-scoped identities use synthetic user IDs that are never persisted and are restricted to data-plane operations; (capacity defined as 1_000_000). The users ids within this pool are rotated (and next avaiable free user ids would be allocated). This can be adjusted if necessary.

Request/response contract

// Request (POST to configured URL)
{
  "credential_type": "password" | "personal_access_token",
  "credential": "...",
  "username": "alice",
  "transport": "tcp" | "http" | "quic" | "websocket",
  "client_address": "10.0.0.1:5000"
}

// Response
{
  "decision": "iggy_user" | "inline_grant" | "deny",
  "user_id": 42,
  "principal": "device-1234",
  "permissions": { "global": { ... } },
  "expires_at": 1700000000,
  "reason": optional string ("certificate revoked")
}

Configuration

[external_auth]
enabled = false
url = "https://auth.example.com/validate"
timeout = "5 s"
on_error = "deny"          # "deny" | "fallback"
forward_credentials = true

E2E validation

Built the server and validated the full external auth flow against a mock auth service.

Startup: Server loaded the config and logged the expected warning:

Test 1: Login ext-alice (inline_grant): Mock received the callout with credential_type, credential (forwarded), username, transport: "http", client_address. Responded with inline_grant granting read_streams, read_topics, poll_messages, send_messages. Server returned HTTP 200 with a JWT for synthetic user_id: 4294967295 (u32::MAX, first minted ID).

Test 2: Login ext-deny (deny): Mock responded {"decision":"deny","reason":"blocked by policy"}. Server returned HTTP 401.

Test 3: GET /streams with alice's token (allowed): HTTP 200, body []. The grant's read_streams: true allowed the read.

Test 4: POST /streams with alice's token (denied): HTTP 403. The grant's manage_streams: false blocked the mutation.

Local Execution

  • Passed
  • Pre-commit hooks ran

AI Usage

If AI tools were used, please answer:

  1. Which tools? Claude, Github Copilot
  2. Scope of usage? Research and implementation (some agents, some auto completes)
  3. How did you verify the generated code works correctly? Also unit tests and validation against mock servers.
  4. Can you explain every line of the code if asked? Yes

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

Thanks for the PR. It is labeled S-waiting-on-review and queued for review.

Slash commands (own line, regular comment) move it around the queue:

  • /ready - back to S-waiting-on-review after addressing feedback
  • /author - flip to S-waiting-on-author while you finish changes
  • /request-review @user-or-team - request a reviewer

See CONTRIBUTING.md for details.

@github-actions github-actions Bot added the S-waiting-on-review PR is waiting on a reviewer label Sep 4, 2026
@hubcio

hubcio commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

hi,
please rebase this PR :)

@hubcio hubcio added S-waiting-on-author PR is waiting on author response and removed S-waiting-on-review PR is waiting on a reviewer labels Sep 6, 2026

@hubcio hubcio 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.

Needs another pass. The callout plumbing is mostly fine; nearly everything below is in the identity and ownership half.

Blockers are inline: the callout follows redirects and resends the credential body, the response cap runs after the body is buffered, HTTP recycles a synthetic id while its JWT is live, the counter is per process under a cluster-wide JWT key, a second Register splits identity from permissions, two paths leak ids until the 1M pool is gone, the binary transport skips the existence and Active checks HTTP enforces, and the metadata gate denies the consumer-group join/leave config.toml advertises.

Rest, on lines the diff does not cover or too small to inline:

  • permissions_global.rs:38 - all ten global bools are mandatory and none of the three levels sets deny_unknown_fields, so a typo silently weakens a grant and an 11th permission breaks every grant service. Parse into a #[serde(default)] DTO.
  • dispatch/reads.rs:172 - GET_ME ungated: an all-false grant gets client id, transport, peer address, consumer-group memberships.
  • dispatch/reads.rs:288 - SYNC_CONSUMER_GROUP ungated. Self-scoped, so nothing crosses callers, but still served to an all-false grant.
  • http/handlers.rs:665 - metrics route takes _identity with no permission check. Predates this PR, but the PR creates a principal class with no permissions.
  • http/jwt.rs:130 - trusted issuers guard user_id == 0 but not the synthetic range, so an issuer mapped there inherits whatever grant holds that id.
  • dispatch/authz.rs:578 - gate_user_scoped returns Ok(()) on an unresolvable target while HTTP runs the predicate, so GET /users/me 403s on HTTP and returns not-found on binary.
  • external_auth.rs:160,171 - url is marked secret, then printed in full in the boot error and the plain-HTTP warning. Userinfo in the URL lands in the log.
  • Synthetic ids are persisted, against "never persisted" in the description: Register prepares carry them into the WAL, checkpoints and the recovered client table. The counter restarts at u32::MAX, so after a restart the same id means a different principal.
  • common/types/user/mod.rs:25 - the threshold and predicate are server-internal but sit in the published iggy_common. The only outside consumer is core/metadata, which already depends on server_common.
  • session_ops.rs:1317 - external auth picks PAT vs password by first match, while the built-in path below deliberately falls back, with a comment saying the shapes collide. A colliding body fails here and succeeds there.
  • config.toml:1108 - forward_credentials = true by default, so enabling the feature POSTs plaintext credentials unless the operator opts out, and the doc block does not say so. Worth flipping to opt-in.
  • http/handlers.rs:198 - client_address is the raw TCP peer, so behind a proxy every login reports the proxy. Honour a trusted-proxy header or document which address it is.
  • No test covers a login on any transport, either authorization plane, the id lifecycle, or on_error. The JSON tests assert on the DTO and never reach the decision mapping, so dropping the required-field checks leaves them green. Three blockers above only show up when you read two transports side by side.

Not blocking: single-use build closure at external_auth.rs:240; thread_local client duplicates http/jwks.rs:44; check_session_permission takes an Option all callers fill; mint and free-list logic written twice; get_connection, collect_expired_sessions, is_session_expired have no callers; session_permissions_for_user walks two maps when callers hold the connection id; stm/authz.rs:140 open-codes is_synthetic_user_id; handlers.rs:1910 re-imports MetadataHandle.

Comment thread core/server/src/external_auth.rs Outdated
Comment thread core/server/src/http/state.rs Outdated
Comment thread core/server/src/http/state.rs Outdated
/// request re-register cleanly through the barrier.
pub(in crate::http) fn forget_session(&self, session: &Rc<HttpSession>) {
let torn = forget_if_same(&mut self.sessions.borrow_mut(), session);
if torn.is_some()

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.

critical: forget_if_same returns None when registry_token is unset even though it already removed the table entry, so this cleanup is skipped. any session that never did an acked produce leaks its permissions entry and burns an id.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

There should be proper cleanup now.

Comment thread core/server/src/dispatch/session_ops.rs Outdated
Comment thread core/server/src/external_auth.rs Outdated
Comment thread core/server/src/http/handlers.rs Outdated
Comment thread core/server/src/http/state.rs Outdated

/// Look up session-scoped permissions for a synthetic user ID. Returns
/// `None` for non-synthetic users or when no permissions are stored.
pub(in crate::http) fn get_synthetic_permissions(&self, user_id: u32) -> Option<Permissions> {

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.

warning: this hands back the grant without checking expires_at, while the binary twin at session_manager.rs:449 does check it. on http an expired grant keeps authorizing until the token itself expires.

#[serde_as(as = "DisplayFromStr")]
#[serde(default = "default_external_auth_timeout")]
#[config_env(leaf)]
pub timeout: IggyDuration,

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.

warning: timeout = "unlimited" (also 0, none, disabled) parses to Duration::ZERO, and the compio timeout then fires on the first poll. with the default on_error = deny that is a total login outage, and nothing validates it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Zero timeout is now rejected at boot.


/// Check if inline permissions allow sending messages to (stream, topic),
/// mirroring the `Permissioner::append_messages` inheritance chain.
pub fn can_send_messages(perms: &Permissions, stream_id: usize, topic_id: usize) -> bool {

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.

simplification: these five predicates re-implement Permissioner rules by hand. init_permissions_for_user builds a one-user permissioner from the same Permissions, dropping ~100 lines plus the closure param threaded through the gates. watch the _ => false at line 211 - the permissioner arm allows there.

Comment thread core/server/src/http/handlers.rs Outdated
@hubcio

hubcio commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

just to add: please wait with rebase until #4092 is merged.

@sansmoraxz

Copy link
Copy Markdown
Author

OK rethinking this, regarding the synthetic IDs probably better to rearchitect.

I think we can have a synthetic ID provider (mapped to specific ID) and that basically enforces a custom ID that's provider generated. (Of course the provider here has to enforce the unique mapping).

This can also remove all the extra plumbing necessary for rotating IDs and such. Especially in cluster mode.

@sansmoraxz
sansmoraxz marked this pull request as draft September 9, 2026 15:04
@github-actions github-actions Bot removed the S-waiting-on-author PR is waiting on author response label Sep 9, 2026
@sansmoraxz

Copy link
Copy Markdown
Author
  • permissions_global.rs:38 - all ten global bools are mandatory and none of the three levels sets deny_unknown_fields, so a typo silently weakens a grant and an 11th permission breaks every grant service. Parse into a #[serde(default)] DTO.

This would likely turn this PR into a breaking change.

@hubcio

hubcio commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

alright, you can ignore this comment for now. i'll check this from my side later this week.

@sansmoraxz

Copy link
Copy Markdown
Author

OK I rebased and went with a different approach now. There's one dedicated ID reserved for external users (which should not collide with local users) and is configurable (but not persisted). Default value if external users enables - u32::MAX. Server boot should crash if ID collision detected at startup and not after server is booted. For external users the main identifiers are unique strings.

Also the auth flow is now local -> external instead of external first. This prevents the potential lag of normal iggy hitting and getting failures on the auth server and then falling back to normal.

Both single node and cluster modes are validated to work without issues.

Side note: segment_recovery tests are currently broken at master which I rebased this from.

@sansmoraxz

Copy link
Copy Markdown
Author

Also some pre-exsting lint issues in other languages (go etc.)

Had to skip verify.

@codecov

codecov Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 85.89%. Comparing base (1290e4f) to head (12d4251).
⚠️ Report is 5 commits behind head on master.

Additional details and impacted files
@@             Coverage Diff              @@
##             master    #4055      +/-   ##
============================================
- Coverage     86.01%   85.89%   -0.13%     
+ Complexity     1451     1448       -3     
============================================
  Files          1248     1248              
  Lines        194939   194866      -73     
  Branches     160243   160164      -79     
============================================
- Hits         167669   167371     -298     
- Misses        23163    23389     +226     
+ Partials       4107     4106       -1     
Components Coverage Δ
Rust Core 86.91% <ø> (-0.01%) ⬇️
Java SDK 67.52% <ø> (-0.04%) ⬇️
C# SDK 76.99% <ø> (-0.08%) ⬇️
Python SDK 91.33% <ø> (+0.01%) ⬆️
PHP SDK 85.65% <ø> (ø)
Node SDK 94.43% <ø> (-1.83%) ⬇️
Go SDK 69.43% <ø> (+0.03%) ⬆️
Files with missing lines Coverage Δ
...common/src/types/permissions/permissions_global.rs 54.95% <ø> (ø)
core/configs/src/common/defaults.rs 100.00% <ø> (ø)
core/configs/src/server_config/defaults.rs 100.00% <ø> (ø)
core/configs/src/server_config/displays.rs 100.00% <ø> (ø)
core/configs/src/server_config/server.rs 90.10% <ø> (ø)
core/metadata/src/impls/metadata.rs 88.92% <ø> (ø)
core/metadata/src/stm/authz.rs 87.15% <ø> (-0.13%) ⬇️
core/metadata/src/stm/mux.rs 90.00% <ø> (-0.09%) ⬇️
core/server/src/boot/listeners.rs 77.21% <ø> (ø)
core/server/src/boot/mod.rs 87.32% <ø> (ø)
... and 20 more

... and 9 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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.

feat(server): Generic external auth callout

2 participants