Skip to content

feat: Service accounts#306

Open
yuvrajjsingh0 wants to merge 3 commits into
mainfrom
service-accounts
Open

feat: Service accounts#306
yuvrajjsingh0 wants to merge 3 commits into
mainfrom
service-accounts

Conversation

@yuvrajjsingh0
Copy link
Copy Markdown
Contributor

@yuvrajjsingh0 yuvrajjsingh0 commented Apr 10, 2026

Summary by CodeRabbit

  • New Features

    • Support for multiple authentication providers (Keycloak, OIDC, Okta, Auth0)
    • Service account creation and management for API access
    • Flexible login/signup: password, OIDC, and provider-specific options now configurable
    • Permission-based authorization system with catalog and batch enforcement APIs
  • API Changes

    • Organization user management now uses email instead of username
    • User creation requires first_name, last_name, and email fields
  • UI Updates

    • Role-based access controls enhanced with permission granularity
    • Login page adapts to enabled authentication methods

@semanticdiff-com
Copy link
Copy Markdown

semanticdiff-com Bot commented Apr 10, 2026

Review changes with  SemanticDiff

Changed Files
File Status
  airborne_dashboard/next.config.mjs  94% smaller
  airborne_dashboard/app/dashboard/[orgId]/[appId]/releases/[releaseId]/page.tsx  90% smaller
  airborne_dashboard/app/login/page.tsx  79% smaller
  airborne_dashboard/app/page.tsx  77% smaller
  airborne_dashboard/app/dashboard/[orgId]/[appId]/views/page.tsx  73% smaller
  airborne_dashboard/app/dashboard/[orgId]/[appId]/token/page.tsx  63% smaller
  airborne_dashboard/app/dashboard/[orgId]/[appId]/files/page.tsx  51% smaller
  airborne_dashboard/providers/app-context.tsx  48% smaller
  airborne_server/src/token.rs  36% smaller
  airborne_dashboard/app/register/page.tsx  34% smaller
  airborne_dashboard/app/dashboard/[orgId]/[appId]/dimensions/page.tsx  29% smaller
  airborne_dashboard/app/oauth/callback/page.tsx  28% smaller
  airborne_server/src/organisation/application/dimension.rs  25% smaller
  airborne_dashboard/components/user-management.tsx  23% smaller
  airborne_server/src/release.rs  21% smaller
  airborne_server/src/file.rs  21% smaller
  airborne_server/src/organisation/application/dimension/cohort.rs  21% smaller
  airborne_server/src/organisation/application/properties.rs  21% smaller
  airborne_server/src/package.rs  21% smaller
  airborne_server/src/file/groups.rs  21% smaller
  airborne_server/src/dashboard/configuration.rs  21% smaller
  airborne_server/src/middleware/auth.rs  19% smaller
  airborne_dashboard/app/dashboard/[orgId]/[appId]/page.tsx  18% smaller
  airborne_dashboard/app/dashboard/[orgId]/[appId]/cohorts/page.tsx  17% smaller
  airborne_server/src/organisation/user/types.rs  17% smaller
  airborne_dashboard/components/release/steps/PackageSelectionStep.tsx  17% smaller
  airborne_server/src/organisation/application/config.rs  17% smaller
  airborne_dashboard/app/dashboard/[orgId]/page.tsx  16% smaller
  airborne_dashboard/app/dashboard/[orgId]/[appId]/releases/create/page.tsx  14% smaller
  airborne_server/src/utils/keycloak.rs  14% smaller
  airborne_dashboard/app/dashboard/[orgId]/[appId]/users/page.tsx  14% smaller
  airborne_dashboard/components/shared-layout.tsx  14% smaller
  airborne_server/src/user.rs  14% smaller
  airborne_dashboard/app/dashboard/[orgId]/[appId]/releases/page.tsx  13% smaller
  airborne_dashboard/app/dashboard/[orgId]/[appId]/packages/page.tsx  13% smaller
  airborne_dashboard/app/dashboard/[orgId]/[appId]/releases/[releaseId]/clone/page.tsx  12% smaller
  airborne_dashboard/app/dashboard/[orgId]/[appId]/releases/[releaseId]/edit/page.tsx  12% smaller
  airborne_server/src/organisation/application/user/types.rs  11% smaller
  airborne_server/src/organisation.rs  10% smaller
  airborne_server/src/organisation/user.rs  9% smaller
  airborne_dashboard/app/dashboard/[orgId]/[appId]/remote-configs/page.tsx  9% smaller
  airborne_server/src/main.rs  9% smaller
  airborne_server/src/organisation/application/user.rs  8% smaller
  airborne_server/src/types.rs  5% smaller
  airborne_dashboard/app/dashboard/[orgId]/users/page.tsx  2% smaller
  airborne_server/src/utils/db/models.rs  2% smaller
  airborne_dashboard/app/dashboard/page.tsx  1% smaller
  API_DOCUMENTATION.md Unsupported file format
  Cargo.lock Unsupported file format
  Cargo.toml Unsupported file format
  Makefile Unsupported file format
  README.md Unsupported file format
  airborne_authz_macros/Cargo.toml Unsupported file format
  airborne_authz_macros/src/lib.rs  0% smaller
  airborne_dashboard/app/dashboard/[orgId]/[appId]/packages/create/page.tsx  0% smaller
  airborne_dashboard/app/dashboard/onboarding/page.tsx  0% smaller
  airborne_dashboard/hooks/use-page-permissions.ts  0% smaller
  airborne_dashboard/lib/authz.ts  0% smaller
  airborne_dashboard/lib/oidc-providers.tsx  0% smaller
  airborne_dashboard/lib/page-permissions.ts  0% smaller
  airborne_server/.env.example Unsupported file format
  airborne_server/Cargo.toml Unsupported file format
  airborne_server/Project.md Unsupported file format
  airborne_server/README.md Unsupported file format
  airborne_server/migrations/20260401120000_add_casbin_rule_and_workspace_uniqueness/down.sql Unsupported file format
  airborne_server/migrations/20260401120000_add_casbin_rule_and_workspace_uniqueness/up.sql Unsupported file format
  airborne_server/migrations/20260409193000_add_authz_bindings_and_memberships/down.sql Unsupported file format
  airborne_server/migrations/20260409193000_add_authz_bindings_and_memberships/up.sql Unsupported file format
  airborne_server/migrations/20260410120000_add_service_accounts/down.sql Unsupported file format
  airborne_server/migrations/20260410120000_add_service_accounts/up.sql Unsupported file format
  airborne_server/scripts/encrypt-envs.sh Unsupported file format
  airborne_server/scripts/init-keycloak.sh Unsupported file format
  airborne_server/scripts/init-localstack.sh Unsupported file format
  airborne_server/src/authz.rs  0% smaller
  airborne_server/src/authz/types.rs  0% smaller
  airborne_server/src/config.rs Unsupported file format
  airborne_server/src/organisation/application.rs Unsupported file format
  airborne_server/src/organisation/application/types.rs  0% smaller
  airborne_server/src/organisation/application/user/transaction.rs  0% smaller
  airborne_server/src/organisation/application/user/utils.rs  0% smaller
  airborne_server/src/organisation/transaction.rs  0% smaller
  airborne_server/src/organisation/types.rs  0% smaller
  airborne_server/src/organisation/user/transaction.rs  0% smaller
  airborne_server/src/organisation/user/utils.rs  0% smaller
  airborne_server/src/provider.rs  0% smaller
  airborne_server/src/provider/authn.rs  0% smaller
  airborne_server/src/provider/authn/auth0.rs  0% smaller
  airborne_server/src/provider/authn/keycloak.rs  0% smaller
  airborne_server/src/provider/authn/oidc.rs  0% smaller
  airborne_server/src/provider/authn/okta.rs  0% smaller
  airborne_server/src/provider/authz.rs  0% smaller
  airborne_server/src/provider/authz/casbin.rs  0% smaller
  airborne_server/src/provider/authz/migration.rs  0% smaller
  airborne_server/src/provider/authz/permission.rs  0% smaller
  airborne_server/src/service_account.rs  0% smaller
  airborne_server/src/service_account/types.rs  0% smaller
  airborne_server/src/user/types.rs  0% smaller
  airborne_server/src/utils.rs  0% smaller
  airborne_server/src/utils/db/schema.rs  0% smaller
  airborne_server/src/utils/transaction_manager.rs  0% smaller
  memory-bank/techContext.md Unsupported file format

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 10, 2026

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: bd14d121-2352-455d-9e2f-400ebb3a03d1

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

This PR implements a major architectural shift from Keycloak-centric to a pluggable OIDC-based authentication provider system combined with Casbin-backed authorization. Changes include refactored authentication middleware, new provider abstractions, new authorization endpoints and permission enforcement, service account management, dashboard permission-based UI gating, database schema extensions for authorization, and configuration/documentation updates.

Changes

Cohort / File(s) Summary
API Documentation & Configuration
API_DOCUMENTATION.md, airborne_server/.env.example, airborne_server/README.md, README.md, Makefile, airborne_server/Project.md
Replaced Keycloak-specific environment variables and documentation with generalized AUTHN_PROVIDER configuration supporting keycloak/oidc/okta/auth0. Updated auth/authz endpoint specifications, environment variable blocks, and OAuth documentation from Google-specific to configurable OIDC provider. Changed keycloak-init execution order in Makefile initialization.
Workspace & Build Configuration
Cargo.toml, airborne_server/Cargo.toml, airborne_dashboard/next.config.mjs
Added airborne_authz_macros to workspace members. Added Rust dependencies: async-trait, casbin, diesel-adapter, openidconnect, inventory. Extended Next.js API rewrite rules to include authz and service-accounts subpaths.
Authentication Provider Layer
airborne_server/src/provider/authn.rs, airborne_server/src/provider/authn/keycloak.rs, airborne_server/src/provider/authn/oidc.rs, airborne_server/src/provider/authn/okta.rs, airborne_server/src/provider/authn/auth0.rs
Introduced pluggable AuthNProvider trait with OIDC/OAuth2 support, metadata caching, PKCE flow, and JWT token verification. Implemented provider-specific implementations for Keycloak (with password/signup/service-account support), OIDC, Okta, and Auth0 (OAuth-only).
Authorization Provider Layer
airborne_server/src/provider/authz.rs, airborne_server/src/provider/authz/casbin.rs, airborne_server/src/provider/authz/permission.rs, airborne_server/src/provider/authz/migration.rs
Introduced AuthZProvider trait abstraction and Casbin-backed implementation with policy management, role hierarchy, membership tracking, and permission enforcement. Added endpoint permission binding registry via procedural macro. Included Keycloak-to-Casbin policy migration path.
Authorization Macro & Types
airborne_authz_macros/Cargo.toml, airborne_authz_macros/src/lib.rs, airborne_server/src/authz.rs, airborne_server/src/authz/types.rs
Added procedural macro #[authz(...)] for declarative endpoint authorization with resource/action/role specifications. Created authorization module exposing /catalog and /me/enforce-batch endpoints for permission discovery and batch enforcement.
Middleware & Core Types
airborne_server/src/middleware/auth.rs, airborne_server/src/types.rs, airborne_server/src/provider.rs
Refactored authentication middleware from direct Keycloak JWT handling to provider-based token verification. Updated AppState/Environment types to include authn/authz provider instances and new configuration fields. Replaced removed error types (OrgAppError, OrgError).
Organization/User Management
airborne_server/src/organisation.rs, airborne_server/src/organisation/user.rs, airborne_server/src/organisation/application.rs, airborne_server/src/organisation/application/user.rs
Replaced Keycloak group-based organization/user management with authz_provider delegation. Removed transaction managers and local group hierarchy logic. Added new endpoints for listing roles/permissions and upserting custom roles. Removed references to access level enums in favor of string-based roles.
Entity Type Updates
airborne_server/src/organisation/application/types.rs, airborne_server/src/organisation/user/types.rs, airborne_server/src/organisation/application/user/types.rs
Removed domain-specific error types (OrgAppError, OrgError) and access level enums. Updated role/access fields to use generic strings. Added DTO types for role/permission listing and upserting.
File/Package/Release/Config Handlers
airborne_server/src/file.rs, airborne_server/src/file/groups.rs, airborne_server/src/package.rs, airborne_server/src/release.rs, airborne_server/src/organisation/application/properties.rs, airborne_server/src/organisation/application/dimension.rs, airborne_server/src/organisation/application/dimension/cohort.rs, airborne_server/src/organisation/application/config.rs
Applied #[authz(...)] declarative authorization guards across all resource handlers, replacing manual validate_user role checks with unified require_org_and_app(...) authorization extraction.
User Authentication Endpoints
airborne_server/src/user.rs, airborne_server/src/user/types.rs, airborne_server/src/token.rs
Replaced direct Keycloak REST calls with authn_provider method delegation for login/signup/OAuth flows. Updated token issuance to support service accounts. Removed JWT decoding and Keycloak admin token management. Extended UserCredentials with first_name/last_name/email fields and OAuthQuery with idp parameter.
Service Accounts
airborne_server/src/service_account.rs, airborne_server/src/service_account/types.rs
Added new service account management module with create/list/delete/rotate endpoints using authn_provider for user provisioning and authz_provider for role assignment.
Keycloak Utilities & Scripts
airborne_server/src/utils/keycloak.rs, airborne_server/scripts/init-keycloak.sh, airborne_server/scripts/init-localstack.sh, airborne_server/scripts/encrypt-envs.sh
Refactored Keycloak utility to use auth_admin_token_url for token acquisition instead of service account client. Updated initialization scripts to handle new OIDC/admin configuration. Changed encrypted secrets list from KEYCLOAK_SECRET to OIDC_CLIENT_SECRET and AUTH_ADMIN_CLIENT_SECRET.
Removed Keycloak Infrastructure
airborne_server/src/organisation/transaction.rs, airborne_server/src/organisation/user/transaction.rs, airborne_server/src/organisation/user/utils.rs, airborne_server/src/organisation/application/user/transaction.rs, airborne_server/src/organisation/application/user/utils.rs, airborne_server/src/utils/transaction_manager.rs
Entirely removed Keycloak-backed transactional group/role management modules, last-admin checks, role hierarchy validation, and distributed transaction cleanup infrastructure.
Database Schema & Models
airborne_server/migrations/20260401120000_add_casbin_rule_and_workspace_uniqueness/up.sql, airborne_server/migrations/20260401120000_add_casbin_rule_and_workspace_uniqueness/down.sql, airborne_server/migrations/20260409193000_add_authz_bindings_and_memberships/up.sql, airborne_server/migrations/20260409193000_add_authz_bindings_and_memberships/down.sql, airborne_server/migrations/20260410120000_add_service_accounts/up.sql, airborne_server/migrations/20260410120000_add_service_accounts/down.sql, airborne_server/src/utils/db/models.rs, airborne_server/src/utils/db/schema.rs
Added Casbin rule table, authz_memberships and authz_role_bindings tables for role-based access control, and service_accounts table. Updated Diesel schema and models to support new tables. Added workspace_names uniqueness constraint.
Dashboard Configuration & Context
airborne_dashboard/providers/app-context.tsx
Extended Configuration interface with OIDC-related fields (enabled_oidc_idps, oidc_login_enabled, password_login_enabled, registration_enabled, authn_provider). Changed fetchConfig to return Configuration
Dashboard Authorization Helpers
airborne_dashboard/lib/page-permissions.ts, airborne_dashboard/lib/authz.ts, airborne_dashboard/lib/oidc-providers.tsx, airborne_dashboard/hooks/use-page-permissions.ts
Added page-level permission system with definePagePermissions, permission helpers, and usePagePermissions hook for batch permission enforcement via /authz/me/enforce-batch. Added OIDC provider resolution and UI components library.
Dashboard Page Authorization
airborne_dashboard/app/dashboard/[orgId]/[appId]/page.tsx, airborne_dashboard/app/dashboard/[orgId]/[appId]/(.*)/page.tsx, airborne_dashboard/app/dashboard/[orgId]/page.tsx, airborne_dashboard/app/dashboard/page.tsx, airborne_dashboard/app/dashboard/onboarding/page.tsx
Replaced hasAppAccess/getOrgAccess/getAppAccess context-based checks with page-level permission definitions and usePagePermissions hook across all app pages (releases, cohorts, dimensions, files, packages, views, token, users, remote-configs, etc.).
Dashboard Components
airborne_dashboard/components/user-management.tsx, airborne_dashboard/components/shared-layout.tsx, airborne_dashboard/components/release/steps/PackageSelectionStep.tsx
Refactored user management component from access-level hierarchy to flexible string-based roles with permission-aware editing. Updated shared layout to use page-level permissions for navigation visibility. Applied permission gating to component-level actions.
Dashboard Authentication Pages
airborne_dashboard/app/login/page.tsx, airborne_dashboard/app/register/page.tsx, airborne_dashboard/app/oauth/callback/page.tsx, airborne_dashboard/app/page.tsx
Replaced hardcoded Google OAuth with dynamic OIDC provider resolution. Added feature flags for password login and registration with conditional UI rendering. Updated OAuth flow to support idp parameter and handle provider-specific signup availability. Extended registration form with first_name/last_name/email fields.
Server Startup & Configuration
airborne_server/src/main.rs, airborne_server/src/config.rs, airborne_server/src/dashboard/configuration.rs
Added CLI mode for Keycloak-to-Casbin authorization migration. Refactored config parsing to handle authn/authz provider selection and OIDC/admin token configuration. Initialize authn/authz providers in AppState. Compute auth capabilities from provider configuration.
Documentation
memory-bank/techContext.md
Updated environment variable documentation to reflect AUTHN_PROVIDER and OIDC configuration instead of Keycloak-specific settings.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Dashboard as Dashboard/Client
    participant AuthEndpoint as Auth Endpoints
    participant AuthN as AuthN Provider
    participant AuthZ as AuthZ Provider
    participant Backend as Backend Services

    rect rgba(100, 150, 255, 0.5)
    Note over User,Backend: OIDC Login Flow
    User->>Dashboard: Click "Sign in"
    Dashboard->>AuthEndpoint: GET /users/oauth/url?idp=google
    AuthEndpoint->>AuthN: get_oauth_url(state, offline=false, idp="google")
    AuthN->>AuthN: Discover OIDC metadata
    AuthN-->>AuthEndpoint: OAuth authorization URL
    AuthEndpoint-->>Dashboard: URL
    Dashboard->>AuthN: Redirect user to OIDC provider
    AuthN->>AuthN: User authenticates at provider
    AuthN-->>Dashboard: Redirect with auth code
    Dashboard->>AuthEndpoint: POST /users/oauth/login with code
    AuthEndpoint->>AuthN: exchange_code_for_token(code, state)
    AuthN->>AuthN: Exchange code for tokens
    AuthN-->>AuthEndpoint: Access/refresh tokens
    AuthEndpoint->>AuthN: verify_access_token(access_token)
    AuthN->>AuthN: Verify JWT signature, validate claims
    AuthN-->>AuthEndpoint: AuthnTokenClaims
    AuthEndpoint->>AuthZ: subject_from_claims(claims)
    AuthZ-->>AuthEndpoint: Normalized subject
    AuthEndpoint->>AuthZ: get_user_access_summary(subject)
    AuthZ-->>AuthEndpoint: User organizations/applications/roles
    AuthEndpoint-->>Dashboard: Login response with token
    end

    rect rgba(100, 255, 150, 0.5)
    Note over User,Backend: Permission Check Flow
    User->>Dashboard: View release page
    Dashboard->>Dashboard: usePagePermissions({read_release, update_release})
    Dashboard->>AuthEndpoint: POST /authz/me/enforce-batch
    AuthEndpoint->>AuthZ: enforce_permissions_batch(subject, checks)
    AuthZ->>AuthZ: Evaluate Casbin policies
    AuthZ-->>AuthEndpoint: [allowed_release_read, allowed_release_update]
    AuthEndpoint-->>Dashboard: Permission results
    Dashboard->>Dashboard: Render UI based on permissions
    end

    rect rgba(255, 200, 100, 0.5)
    Note over User,Backend: Authenticated Request Flow
    User->>Dashboard: Create release
    Dashboard->>Backend: POST /release (with Bearer token)
    Backend->>AuthEndpoint: Verify token (middleware)
    AuthEndpoint->>AuthN: verify_access_token(token)
    AuthN-->>AuthEndpoint: AuthnTokenClaims
    AuthEndpoint->>AuthZ: access_for_request(claims, org, app)
    AuthZ-->>AuthEndpoint: Access context
    Backend->>Backend: Handler with #[authz(...)] macro
    Backend->>AuthZ: enforce_permission(subject, resource, action)
    AuthZ->>AuthZ: Check Casbin policy
    AuthZ-->>Backend: Permission decision
    Backend->>Backend: Execute business logic
    Backend-->>Dashboard: Success response
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

authentication, authorization, refactoring, backend, frontend, database

Suggested reviewers

  • JamesGeorg

Poem

🐰 From Keycloak's halls to providers so grand,
Authorization flows across the land,
With Casbin's rules and permissions so clear,
The system now scales, no single point dear,
Dashboard learns permissions, roles take their place,
A modular auth—what a wonderful grace! ✨

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch service-accounts

Comment thread airborne_server/src/authz.rs Dismissed
Comment thread airborne_server/src/authz.rs Dismissed
Comment thread airborne_server/src/provider/authz/migration.rs Dismissed
Comment on lines +92 to +97
#[authz(
resource = "service_account",
action = "create",
org_roles = ["owner", "admin"],
app_roles = []
)]
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 17

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

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

⚠️ Outside diff range comments (2)
airborne_dashboard/app/dashboard/[orgId]/[appId]/releases/page.tsx (1)

56-74: ⚠️ Potential issue | 🟡 Minor

Missing notFound() guard for read_releases permission.

Other pages in this PR (e.g., [appId]/page.tsx, packages/create/page.tsx) call notFound() when the user lacks the required read permission. This page fetches releases data but doesn't block rendering when read_releases is denied—the user sees the full page structure while the API returns 403.

For consistency, consider adding the same guard pattern:

Suggested fix
  const { token, org, app } = useAppContext();
  const permissions = usePagePermissions(PAGE_AUTHZ);
+ 
+ if (permissions.isReady && !permissions.can("read_releases")) {
+   notFound();
+ }

You'll also need to import notFound from next/navigation.

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

In `@airborne_dashboard/app/dashboard/`[orgId]/[appId]/releases/page.tsx around
lines 56 - 74, This page does not guard against missing read_releases
permission, so users without that permission still see the UI while the API
returns 403; update the component to call notFound() (imported from
next/navigation) when usePagePermissions(PAGE_AUTHZ).can("read_releases") is
false—e.g., after const permissions = usePagePermissions(PAGE_AUTHZ) check
permissions.can("read_releases") and invoke notFound() to short-circuit
rendering before useSWR/apiFetch (ensure this runs before the SWR call that uses
token/org/appId to avoid fetching forbidden data).
airborne_dashboard/app/dashboard/[orgId]/[appId]/releases/[releaseId]/edit/page.tsx (1)

19-45: ⚠️ Potential issue | 🟠 Major

Handle permission lookup errors before notFound().

As written, any usePagePermissions failure makes hasAccess false and turns the edit page into a 404. That hides real authz outages and makes troubleshooting hard. Check permissions.error separately, then call notFound() only on an actual deny.

Suggested guard
   if (!permissions.isReady) {
     return (
       <div className="p-6 flex items-center justify-center min-h-screen">
         <p className="text-muted-foreground">Checking access...</p>
       </div>
     );
   }
 
+  if (permissions.error) {
+    return (
+      <div className="p-6 flex items-center justify-center min-h-screen">
+        <p className="text-destructive">Failed to verify access.</p>
+      </div>
+    );
+  }
+
   const hasAccess = permissions.can("read_release") && permissions.can("update_release");

Based on learnings from airborne_dashboard/hooks/use-page-permissions.ts:1-70, isReady becomes true when the permission request errors, and can() falls back to false.

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

In
`@airborne_dashboard/app/dashboard/`[orgId]/[appId]/releases/[releaseId]/edit/page.tsx
around lines 19 - 45, The current logic treats any permissions lookup failure as
a deny and calls notFound(); instead, check permissions.error first and surface
it (e.g., throw or render an error) before treating the result as a deny: after
calling usePagePermissions use permissions.isReady and if permissions.error is
set return/render an error (or rethrow) so authz outages are visible, then
compute hasAccess using permissions.can(...) only when there is no
permissions.error and permissions.isReady, and call notFound() only when the
permission checks explicitly deny access.
🟡 Minor comments (6)
airborne_server/Project.md-323-328 (1)

323-328: ⚠️ Potential issue | 🟡 Minor

Move OIDC configuration variables to backend documentation section.

The environment variables OIDC_ISSUER_URL and OIDC_CLIENT_ID documented in lines 326-327 are not used by the Next.js frontend (airborne_dashboard). These are backend configuration variables required by airborne_server for OIDC authentication flows (provider metadata discovery, token verification, OAuth flows). Since the frontend retrieves OIDC configuration from the backend API endpoint (/dashboard/configuration), these variables belong in backend environment documentation, not the frontend "Environment Configuration" section. Either move them to the backend documentation or remove them from this section.

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

In `@airborne_server/Project.md` around lines 323 - 328, The OIDC vars
OIDC_ISSUER_URL and OIDC_CLIENT_ID belong to the backend, not the frontend, so
remove them from the frontend "Environment Configuration" block in Project.md
and either relocate them into the backend/server documentation for
airborne_server or delete them if already documented there; reference
airborne_dashboard (frontend) which uses /dashboard/configuration to fetch OIDC
settings and airborne_server which requires OIDC_ISSUER_URL and OIDC_CLIENT_ID
for provider discovery/token verification, and update the Project.md section
accordingly to avoid duplicating backend-only env vars.
README.md-325-325 (1)

325-325: ⚠️ Potential issue | 🟡 Minor

Capitalize GitHub in the IdP example.

Line 325 uses github, but the platform name is GitHub.

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

In `@README.md` at line 325, Update the example value for the `OIDC_ENABLED_IDPS`
table entry so the provider name is capitalized correctly: change the
comma-separated IdP hint from `google,github` to `google,GitHub` in the
`OIDC_ENABLED_IDPS` row of README.md.
airborne_server/src/organisation/application/user/types.rs-4-8 (1)

4-8: ⚠️ Potential issue | 🟡 Minor

Breaking change: access field type changed from enum to String.

The UserRequest.access field changed from a typed AccessLvl enum to a plain String. This is a breaking change for API consumers who may have been relying on compile-time validation of access levels. Ensure this is documented in migration notes or API changelog.

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

In `@airborne_server/src/organisation/application/user/types.rs` around lines 4 -
8, UserRequest's access field was changed from the typed AccessLvl enum to a
plain String (UserRequest.access), which is a breaking API change; restore
type-safety by reverting access back to the AccessLvl enum (use AccessLvl for
UserRequest.access and derive/implement serde Deserialize for AccessLvl) or, if
String is required, add runtime validation/deserialization that maps/validates
the String into AccessLvl (e.g., TryFrom or custom Deserialize for AccessLvl)
and update the API changelog/migration notes to document the breaking change and
accepted values.
API_DOCUMENTATION.md-179-179 (1)

179-179: ⚠️ Potential issue | 🟡 Minor

Minor: Capitalize "GitHub".

The official name uses a capital "H".

📝 Proposed fix
-- `idp` (optional, string): OIDC provider hint (for Keycloak this maps to `kc_idp_hint`, for example `google` or `github`).
+- `idp` (optional, string): OIDC provider hint (for Keycloak this maps to `kc_idp_hint`, for example `google` or `GitHub`).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@API_DOCUMENTATION.md` at line 179, Update the API docs string for the `idp`
OIDC provider hint to use the correct capitalization for GitHub; change the
example text from "github" to "GitHub" (the `idp` field and the inline example
showing `google` or `github` should be updated so `github` is capitalized as
`GitHub`).
airborne_dashboard/components/user-management.tsx-107-107 (1)

107-107: ⚠️ Potential issue | 🟡 Minor

Verify role key validation matches backend.

The isValidRoleKey regex /^[a-z_]+$/ allows only lowercase letters and underscores. Verify this matches the backend validation in casbin.rs:

// From casbin.rs line 2229-2233
fn is_valid_custom_role_name(role: &str) -> bool {
    !role.is_empty()
        && role.len() <= 64
        && role.chars().all(|ch| ch.is_ascii_lowercase() || ch == '_')
}

The frontend validation is missing the 64-character length limit. Consider adding:

-const isValidRoleKey = (value: string): boolean => /^[a-z_]+$/.test(value);
+const isValidRoleKey = (value: string): boolean => /^[a-z_]+$/.test(value) && value.length <= 64;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@airborne_dashboard/components/user-management.tsx` at line 107, Update the
frontend role-key validator to match backend rules: modify isValidRoleKey to
enforce non-empty, maximum length 64, and allow only ASCII lowercase letters or
underscores (same as casbin.rs is_valid_custom_role_name); locate the
isValidRoleKey function and add a length check (value.length > 0 && value.length
<= 64) in addition to the existing /^[a-z_]+$/ test so the validation behavior
matches the backend precisely.
airborne_server/src/user.rs-255-262 (1)

255-262: ⚠️ Potential issue | 🟡 Minor

Inconsistent error types for token verification failure.

oauth_login (line 261) maps token verification failure to ABError::BadRequest("Invalid token"), while oauth_signup (line 298) maps the same error to ABError::Unauthorized("Invalid token"). For consistency and correct HTTP semantics (401 = authentication failure), both should use Unauthorized:

Proposed fix
// In oauth_login (lines 259-262)
         .map_err(|e| {
             info!("[OAUTH_LOGIN] Token decode failed: {:?}", e);
-            ABError::BadRequest("Invalid token".to_string())
+            ABError::Unauthorized("Invalid token".to_string())
         })?;

Also applies to: 294-298

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

In `@airborne_server/src/user.rs` around lines 255 - 262, The token verification
error mapping is inconsistent: in oauth_login the verify_access_token call
(state.authn_provider.verify_access_token) maps failures to ABError::BadRequest
while oauth_signup maps to ABError::Unauthorized; change the mapping closure
used after verify_access_token in both oauth_login and oauth_signup to return
ABError::Unauthorized("Invalid token".to_string()) so both functions
consistently return a 401 for authentication failures.
🧹 Nitpick comments (18)
airborne_dashboard/app/dashboard/[orgId]/[appId]/page.tsx (1)

28-37: Consider gating the API fetch on permission readiness.

The SWR fetch starts before permissions.isReady, which may trigger unnecessary 403 responses. The existing ErrorName.Forbidden fallback at lines 32-34 handles this gracefully, but you could reduce redundant network calls by adding permissions.isReady && permissions.can("read_releases") to the SWR key condition.

Current approach works correctly due to the dual fallback, so this is optional.

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

In `@airborne_dashboard/app/dashboard/`[orgId]/[appId]/page.tsx around lines 28 -
37, The SWR request is initiated before permissions are ready, causing
unnecessary 403s; update the useSWR key condition so the fetch only runs when
permissions.isReady and permissions.can("read_releases") are true (in addition
to token, org, and appId). Specifically, modify the useSWR call in page.tsx (the
one invoking useSWR and apiFetch) to include permissions.isReady &&
permissions.can("read_releases") in the conditional key, while keeping the
existing error handling that checks error.name === ErrorName.Forbidden and the
notFound() fallbacks unchanged.
airborne_dashboard/app/dashboard/[orgId]/[appId]/remote-configs/page.tsx (1)

57-61: Missing fetchSchema in useEffect dependencies.

The fetchSchema function references canReadSchema, token, org, and app from the closure. While the explicit dependencies cover the trigger conditions, fetchSchema itself should either be in the dependency array or wrapped in useCallback to avoid stale closure issues if its captured variables change mid-render.

Suggested fix using useCallback
+ import { useCallback } from "react";
  // ...
- const fetchSchema = async () => {
+ const fetchSchema = useCallback(async () => {
    if (!canReadSchema) return;
    // ... rest of function
- };
+ }, [canReadSchema, token, org, app]);

  useEffect(() => {
    if (token && orgId && appId && permissions.isReady && canReadSchema) {
      fetchSchema();
    }
- }, [token, orgId, appId, permissions.isReady, canReadSchema]);
+ }, [token, orgId, appId, permissions.isReady, canReadSchema, fetchSchema]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@airborne_dashboard/app/dashboard/`[orgId]/[appId]/remote-configs/page.tsx
around lines 57 - 61, The useEffect depends on values used inside fetchSchema
but omits fetchSchema itself, risking stale closures; update the code so
fetchSchema is stable (wrap the fetchSchema function in useCallback with token,
orgId, appId, canReadSchema, and any other captured values as its dependencies)
and then include fetchSchema in the useEffect dependency array (or alternatively
include fetchSchema directly in the deps if you keep it as a plain function).
Ensure the unique symbols mentioned (fetchSchema and the useEffect that
currently lists [token, orgId, appId, permissions.isReady, canReadSchema]) are
updated so the effect always runs when the actual fetch logic or its captured
values change.
airborne_dashboard/app/dashboard/[orgId]/[appId]/files/page.tsx (1)

159-160: Potential duplicate error toast.

Per project conventions, apiFetch already displays an error toast on API failures by default. The manual toastError call here may result in duplicate user-visible error notifications. Consider removing the manual toast or disabling showErrorToast on the apiFetch call if custom error messaging is needed.

♻️ Remove duplicate toast
     } catch (error) {
-      toastError("Failed to update tag", error instanceof Error ? error.message : "Unknown error");
+      // apiFetch already shows error toast; only handle non-UI cleanup here
     } finally {

Based on learnings: "When using apiFetch from airborne_dashboard/lib/api.ts, note that it already shows an error toast on API failures by default... do not manually trigger toast/notification UI for API errors."

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

In `@airborne_dashboard/app/dashboard/`[orgId]/[appId]/files/page.tsx around lines
159 - 160, The catch block for the tag update is producing a manual toast via
toastError which duplicates apiFetch's built-in error toast; remove the manual
toastError call in the catch (or alternatively call apiFetch with
showErrorToast: false if you want a custom message) and let apiFetch handle
error toasts, updating the catch in the updateTag/handleUpdateTag flow
accordingly (referencing the catch block around the update tag operation and the
apiFetch/showErrorToast option).
airborne_dashboard/app/dashboard/[orgId]/[appId]/dimensions/page.tsx (2)

127-133: Potential duplicate error toast in handleCreate.

Similar to the files page, apiFetch already displays error toasts by default. The manual toast in the catch block may result in duplicate notifications.

♻️ Remove duplicate toast
     } catch (error) {
       console.error("Failed to create dimension:", error);
-      toast({
-        title: "Error",
-        description: "Failed to create dimension. Please try again.",
-        variant: "destructive",
-      });
+      // apiFetch already shows error toast
     } finally {

Based on learnings: "When using apiFetch... do not manually trigger toast/notification UI for API errors."

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

In `@airborne_dashboard/app/dashboard/`[orgId]/[appId]/dimensions/page.tsx around
lines 127 - 133, In handleCreate, remove the manual toast call in the catch
block to avoid duplicate notifications since apiFetch already shows error
toasts; keep or adjust the console.error/logging of the caught error (e.g., the
existing console.error("Failed to create dimension:", error)) but delete the
toast({...}) invocation so only apiFetch controls user-facing error messages.

151-157: Same duplicate toast pattern in movePriority.

The manual error toast here also duplicates apiFetch's built-in error notification.

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

In `@airborne_dashboard/app/dashboard/`[orgId]/[appId]/dimensions/page.tsx around
lines 151 - 157, The catch block in movePriority duplicates apiFetch's built-in
error toast; in the movePriority function remove the manual toast and
console.error so errors are allowed to be handled by apiFetch's notification (or
rethrow if upstream needs it), keeping only any necessary cleanup/return
logic—locate the catch inside movePriority that currently logs "Failed to update
priority:" and remove the toast() and console.error calls there.
airborne_server/scripts/init-keycloak.sh (1)

1-1: Shebang declares POSIX sh but script uses local keyword.

The script uses #!/bin/sh but employs the local keyword (lines 106, 111-114, 146-147, 185, 242) which is undefined in POSIX sh. While most systems link /bin/sh to a shell that supports local, this isn't guaranteed and could fail on strict POSIX environments.

Consider changing the shebang to #!/bin/bash or #!/usr/bin/env bash if bash features are acceptable, or replace local with function-scoped variables using subshells.

♻️ Proposed fix
-#!/bin/sh
+#!/usr/bin/env bash
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@airborne_server/scripts/init-keycloak.sh` at line 1, The script declares a
POSIX sh shebang but uses the Bash-only keyword "local" in several functions,
which can break on strict /bin/sh implementations; update the shebang to a
Bash-compatible interpreter (e.g., change "#!/bin/sh" to "#!/usr/bin/env bash")
or remove all "local" usages by rewriting those functions to use
POSIX-compatible patterns (e.g., use temporary subshells or uniquely named
variables) — search for the "local" occurrences in init-keycloak.sh (the
functions where "local" is used at the noted occurrences) and apply the chosen
fix consistently across those functions.
airborne_dashboard/hooks/use-page-permissions.ts (1)

53-56: Consider documenting the permissive default when no permissions are defined.

When aliases.length === 0, can() returns true. This is a permissive default that grants access when no permissions are configured. While this may be intentional for backward compatibility, it could be surprising behavior. Consider adding a brief comment explaining this design choice.

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

In `@airborne_dashboard/hooks/use-page-permissions.ts` around lines 53 - 56, The
can function currently returns true when aliases.length === 0, a permissive
default that grants access if no permissions are configured; add a brief inline
comment above the can function explaining this intentional fallback (e.g.,
"permissive default for backward compatibility: allow access when no permission
aliases are defined") so future readers understand the design choice and
potential implications; reference the can function and the aliases/decisions
variables when adding the comment.
airborne_authz_macros/src/lib.rs (1)

99-128: Silent fallback to "UNKNOWN" method may mask misconfiguration.

When no HTTP method attribute (get, post, etc.) is found, the function returns "UNKNOWN" as the method (line 127). This silently allows the macro to compile but may cause issues at runtime when the endpoint binding is registered with an invalid method.

Consider emitting a compile-time warning or error when no HTTP method attribute is detected.

♻️ Suggested improvement
 fn parse_method_and_path(attrs: &[Attribute]) -> (String, String) {
     for attr in attrs {
         // ... existing parsing logic ...
     }
 
-    ("UNKNOWN".to_string(), String::new())
+    // This will be caught at runtime, but ideally we'd warn at compile time
+    // Consider: eprintln!("cargo:warning=No HTTP method attribute found on #[authz] decorated function");
+    ("UNKNOWN".to_string(), String::new())
 }
airborne_server/src/organisation.rs (1)

183-192: Consider fetching actual user access from authz provider instead of hardcoding.

The response returns a hardcoded access array ["owner", "admin", "write", "read"]. While this happens to be correct (since the creator receives the owner role via create_organisation in the authz provider, and org_role_display_expansion("owner") expands to all four roles), this creates an implicit contract that isn't validated. If the authz logic changes to grant a different initial role, the hardcoded response would become incorrect and inconsistent with list_organisations, which fetches actual access via org_role_display_expansion().

For consistency and maintainability, consider computing the access array from the actual role granted by the authz provider rather than hardcoding it.

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

In `@airborne_server/src/organisation.rs` around lines 183 - 192, Instead of
returning the hardcoded access array in the Organisation JSON, call the authz
provider to determine the creator's actual organisation role and derive the
displayed access via the same helper used in list_organisations (e.g.,
org_role_display_expansion). Replace the literal
vec!["owner","admin","write","read"] in the Json(Organisation { ... }) return
with a computed access list by fetching the creator's role from the authz logic
used by create_organisation (or the authz client/function that returns the
granted role) and then pass that role into org_role_display_expansion (or the
equivalent helper) so the returned access is consistent with the real authz
state.
airborne_dashboard/app/dashboard/[orgId]/users/page.tsx (1)

499-502: Hardcoded service account email domain filter.

The filter uses a hardcoded domain @service-account.airborne.juspay.in. Consider extracting this to a constant or deriving it from configuration for maintainability.

♻️ Suggested improvement
+const SERVICE_ACCOUNT_EMAIL_SUFFIX = "@service-account.airborne.juspay.in";
+
 // Filter out service account users from the regular users list
 const regularUsers = (data?.users || []).filter(
-  (user) => !user.username.endsWith("@service-account.airborne.juspay.in")
+  (user) => !user.username.endsWith(SERVICE_ACCOUNT_EMAIL_SUFFIX)
 );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@airborne_dashboard/app/dashboard/`[orgId]/users/page.tsx around lines 499 -
502, The filter that builds regularUsers currently hardcodes the service account
domain in the expression
user.username.endsWith("@service-account.airborne.juspay.in"); extract that
literal into a named constant (e.g., SERVICE_ACCOUNT_DOMAIN) or pull it from
configuration/env (e.g., getConfig().serviceAccountDomain) and replace the
direct string usage so the filter becomes
user.username.endsWith(SERVICE_ACCOUNT_DOMAIN), ensuring the constant/config is
exported/loaded where page.tsx can access it and add a brief comment explaining
why it’s configurable.
airborne_server/src/service_account.rs (2)

314-322: Rotation silently ignores deletion failure.

If delete_user fails at Line 314-317 but create_service_account_user succeeds, the old OIDC user might still exist with the previous credentials. Consider logging the deletion result.

♻️ Proposed logging
     // Delete and recreate the OIDC user with new credentials
     let password = generate_random_password().await?;
-    let _ = state
+    if let Err(e) = state
         .authn_provider
         .delete_user(state.get_ref(), &entry.name)
-        .await;
+        .await
+    {
+        log::warn!("[ROTATE SERVICE ACCOUNT] Failed to delete old OIDC user: {}", e);
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@airborne_server/src/service_account.rs` around lines 314 - 322, The call to
state.authn_provider.delete_user(...) is currently ignored, which can leave the
old OIDC user in place if deletion fails; update the rotation flow in the
function containing the delete_user and create_service_account_user calls to
capture the Result/Err from state.authn_provider.delete_user(state.get_ref(),
&entry.name).await, and log a warning/error (including the error details and
entry.name) when deletion fails before proceeding to call
state.authn_provider.create_service_account_user(...); keep the
create_service_account_user call behavior but ensure the deletion failure is not
silently dropped—use the existing logging facility (process logger or state
logger) to emit contextual logs.

245-255: Best-effort cleanup may leave orphaned resources.

The let _ = pattern silently ignores failures when removing AuthZ memberships or deleting the OIDC user. If the OIDC user deletion fails, the user remains in Keycloak but the DB entry is deleted, potentially leaving orphaned credentials.

Consider logging these failures at minimum for audit/debugging purposes.

♻️ Proposed logging for cleanup failures
     // Remove from AuthZ memberships
-    let _ = state
+    if let Err(e) = state
         .authz_provider
         .remove_organisation_user(state.get_ref(), &auth.sub, &organisation, &entry.email)
-        .await;
+        .await
+    {
+        log::warn!("[DELETE SERVICE ACCOUNT] Failed to remove AuthZ membership: {}", e);
+    }

     // Delete user from OIDC provider (best effort — invalidates all tokens)
-    let _ = state
+    if let Err(e) = state
         .authn_provider
         .delete_user(state.get_ref(), &entry.name)
-        .await;
+        .await
+    {
+        log::warn!("[DELETE SERVICE ACCOUNT] Failed to delete OIDC user: {}", e);
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@airborne_server/src/service_account.rs` around lines 245 - 255, The cleanup
currently swallows errors with `let _ =` for
state.authz_provider.remove_organisation_user and
state.authn_provider.delete_user; change these to capture the Result and log
failures (e.g., call the service/state logger with an error level) including
contextual info (auth.sub, organisation, entry.email or entry.name) so you can
audit when AuthZ removal or OIDC deletion fails — update the calls around
remove_organisation_user and delete_user to handle .await -> match/if let
Err(err) and log the error with the identifying fields.
airborne_server/src/provider/authn/keycloak.rs (1)

138-141: Consider reusing reqwest::Client instances.

Creating a new reqwest::Client on each call involves connection pool setup overhead. While not critical, consider passing a shared client via AppState or reusing a single instance within multiple calls.

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

In `@airborne_server/src/provider/authn/keycloak.rs` around lines 138 - 141, The
code creates new reqwest::Client instances for get_token and KeycloakAdmin::new
which wastes connection pools; update the code and AppState to hold a shared
reqwest::Client (e.g., add an http_client field to the AppState struct) and use
that shared client when calling get_token(...) and KeycloakAdmin::new(...)
(replace Client::new() with &state.http_client or state.http_client.clone() as
appropriate), ensuring get_token and KeycloakAdmin constructors accept a Client
reference/clone if needed.
airborne_server/src/provider/authz/casbin.rs (1)

556-567: Full-table delete in refresh_membership_cache_from_casbin is safe only during bootstrap.

The diesel::delete(authz_memberships::table).execute(&mut conn)? on line 559 removes all membership rows before re-inserting from Casbin policies. This is acceptable during bootstrap() initialization, but if this method were called at runtime (e.g., during policy reload), it would create a brief window where membership queries return no results.

Consider adding a doc comment to clarify this method is intended for bootstrap-only use, or wrap the delete+insert in a transaction with explicit isolation level.

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

In `@airborne_server/src/provider/authz/casbin.rs` around lines 556 - 567, The
current implementation in refresh_membership_cache_from_casbin deletes all rows
from authz_memberships::table then re-inserts, which is only safe during
bootstrap; either document this intent or make the operation atomic: add a doc
comment on refresh_membership_cache_from_casbin stating it is bootstrap-only
(referencing bootstrap()), or wrap the delete+insert block in an explicit DB
transaction (use the connection's transaction API and set an appropriate
isolation level) so delete and subsequent inserts occur atomically and do not
expose an empty-membership window at runtime.
airborne_server/src/organisation/user.rs (1)

65-65: Unused auth_response parameter in handlers.

The auth_response: ReqData<AuthResponse> parameter is injected by the #[authz(...)] macro for permission enforcement, but each handler then calls get_org_context(&req) which re-extracts AuthResponse from request extensions. Consider using the auth_response directly to avoid the redundant extraction:

-async fn get_org_context(req: &HttpRequest) -> airborne_types::Result<(String, AuthResponse)> {
-    let auth = req
-        .extensions()
-        .get::<AuthResponse>()
-        .cloned()
-        .ok_or_else(|| ABError::Unauthorized("Missing auth context".to_string()))?;
-
-    let org_name = require_scope_name(auth.organisation.clone(), "organisation")?;
-    Ok((org_name, auth))
-}
+fn get_org_name(auth: &AuthResponse) -> airborne_types::Result<String> {
+    require_scope_name(auth.organisation.clone(), "organisation")
+}

Then in handlers:

-    let (organisation, auth) = get_org_context(&req).await?;
+    let auth = auth_response.into_inner();
+    let organisation = get_org_name(&auth)?;

Also applies to: 112-112, 151-151, 179-179, 210-210, 237-237, 275-275, 306-306

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

In `@airborne_server/src/organisation/user.rs` at line 65, The handlers currently
accept the injected parameter auth_response: ReqData<AuthResponse> from the
#[authz(...)] macro but ignore it and call get_org_context(&req) which
re-extracts AuthResponse from request extensions; update each handler to use the
provided auth_response instead of re-extracting (either pass
auth_response.into_inner()/auth_response.0 to get_org_context after changing its
signature to accept an AuthResponse, or remove the redundant get_org_context
call and derive org context directly from the auth_response), and update all
affected handler functions that declare auth_response: ReqData<AuthResponse>
(the locations noted in the comment) so they consume the injected value rather
than re-reading from req.
airborne_server/src/provider/authz/permission.rs (1)

48-50: Minor format inconsistency in error message.

The scoped_permission function formats permissions as "{scope}:{resource}.{action}", but the error message on line 103-104 uses "{resource}.{action}" without the scope prefix. This inconsistency is benign for user-facing errors but could cause confusion during debugging.

Also applies to: 102-105

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

In `@airborne_server/src/provider/authz/permission.rs` around lines 48 - 50, The
error message currently prints permissions as "{resource}.{action}" which
mismatches scoped_permission(scope, resource, action) that formats
"{scope}:{resource}.{action}"; update the error(s) that construct or log the
permission string (where the message omits the scope) to use the same
format—either call scoped_permission(scope, resource, action) or replicate
"{scope}:{resource}.{action}"—so logs and errors consistently include the scope
prefix (check places that build the permission string in the permission
error/path handling).
airborne_server/src/middleware/auth.rs (1)

70-76: Consider documenting the purpose of authn_* fields.

The #[allow(dead_code)] attributes on authn_sub, authn_iss, and authn_email suggest these fields are reserved for future use. A brief doc comment explaining their intended purpose (e.g., audit logging, debugging, or future multi-provider scenarios) would help maintainers understand why they're preserved.

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

In `@airborne_server/src/middleware/auth.rs` around lines 70 - 76, The three
fields authn_sub, authn_iss, and authn_email are marked with #[allow(dead_code)]
but lack documentation; add short doc-comments above each field in the struct
(or a doc-block for the group) explaining their intended purpose (e.g., to hold
the original authentication subject, issuer, and email for audit/logging,
debugging, or future multi-provider support) so maintainers know why they are
retained and when they should be used; reference the fields authn_sub,
authn_iss, and authn_email when adding these comments.
airborne_server/src/utils/keycloak.rs (1)

81-108: Consider documenting the realm_users_get parameter positions.

The call to realm_users_get uses many positional None parameters (lines 89-101), which can be fragile if the Keycloak crate's API changes. A brief inline comment indicating which parameter is enabled (line 92) and which is search (line 102) would improve maintainability.

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

In `@airborne_server/src/utils/keycloak.rs` around lines 81 - 108, In
search_users, annotate the positional None arguments passed to
KeycloakAdmin::realm_users_get so future readers know which slots are which
(e.g., mark the argument currently set to Some(true) as the "enabled" parameter
and the final Some(search_term.to_string()) as the "search" parameter); locate
this call inside the async fn search_users and add short inline comments after
the relevant arguments to label "enabled" and "search" (or replace with named
builder-style params if the crate supports it) to improve maintainability.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c5dacfc6-d38a-4c47-a623-e543cd546af6

📥 Commits

Reviewing files that changed from the base of the PR and between fd3e886 and 8838b07.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (100)
  • API_DOCUMENTATION.md
  • Cargo.toml
  • Makefile
  • README.md
  • airborne_authz_macros/Cargo.toml
  • airborne_authz_macros/src/lib.rs
  • airborne_dashboard/app/dashboard/[orgId]/[appId]/cohorts/page.tsx
  • airborne_dashboard/app/dashboard/[orgId]/[appId]/dimensions/page.tsx
  • airborne_dashboard/app/dashboard/[orgId]/[appId]/files/page.tsx
  • airborne_dashboard/app/dashboard/[orgId]/[appId]/packages/create/page.tsx
  • airborne_dashboard/app/dashboard/[orgId]/[appId]/packages/page.tsx
  • airborne_dashboard/app/dashboard/[orgId]/[appId]/page.tsx
  • airborne_dashboard/app/dashboard/[orgId]/[appId]/releases/[releaseId]/clone/page.tsx
  • airborne_dashboard/app/dashboard/[orgId]/[appId]/releases/[releaseId]/edit/page.tsx
  • airborne_dashboard/app/dashboard/[orgId]/[appId]/releases/[releaseId]/page.tsx
  • airborne_dashboard/app/dashboard/[orgId]/[appId]/releases/create/page.tsx
  • airborne_dashboard/app/dashboard/[orgId]/[appId]/releases/page.tsx
  • airborne_dashboard/app/dashboard/[orgId]/[appId]/remote-configs/page.tsx
  • airborne_dashboard/app/dashboard/[orgId]/[appId]/token/page.tsx
  • airborne_dashboard/app/dashboard/[orgId]/[appId]/users/page.tsx
  • airborne_dashboard/app/dashboard/[orgId]/[appId]/views/page.tsx
  • airborne_dashboard/app/dashboard/[orgId]/page.tsx
  • airborne_dashboard/app/dashboard/[orgId]/users/page.tsx
  • airborne_dashboard/app/dashboard/onboarding/page.tsx
  • airborne_dashboard/app/dashboard/page.tsx
  • airborne_dashboard/app/login/page.tsx
  • airborne_dashboard/app/oauth/callback/page.tsx
  • airborne_dashboard/app/page.tsx
  • airborne_dashboard/app/register/page.tsx
  • airborne_dashboard/components/release/steps/PackageSelectionStep.tsx
  • airborne_dashboard/components/shared-layout.tsx
  • airborne_dashboard/components/user-management.tsx
  • airborne_dashboard/hooks/use-page-permissions.ts
  • airborne_dashboard/lib/authz.ts
  • airborne_dashboard/lib/oidc-providers.tsx
  • airborne_dashboard/lib/page-permissions.ts
  • airborne_dashboard/next.config.mjs
  • airborne_dashboard/providers/app-context.tsx
  • airborne_server/.env.example
  • airborne_server/Cargo.toml
  • airborne_server/Project.md
  • airborne_server/README.md
  • airborne_server/migrations/20260401120000_add_casbin_rule_and_workspace_uniqueness/down.sql
  • airborne_server/migrations/20260401120000_add_casbin_rule_and_workspace_uniqueness/up.sql
  • airborne_server/migrations/20260409193000_add_authz_bindings_and_memberships/down.sql
  • airborne_server/migrations/20260409193000_add_authz_bindings_and_memberships/up.sql
  • airborne_server/migrations/20260410120000_add_service_accounts/down.sql
  • airborne_server/migrations/20260410120000_add_service_accounts/up.sql
  • airborne_server/scripts/encrypt-envs.sh
  • airborne_server/scripts/init-keycloak.sh
  • airborne_server/scripts/init-localstack.sh
  • airborne_server/src/authz.rs
  • airborne_server/src/authz/types.rs
  • airborne_server/src/config.rs
  • airborne_server/src/dashboard/configuration.rs
  • airborne_server/src/file.rs
  • airborne_server/src/file/groups.rs
  • airborne_server/src/main.rs
  • airborne_server/src/middleware/auth.rs
  • airborne_server/src/organisation.rs
  • airborne_server/src/organisation/application.rs
  • airborne_server/src/organisation/application/config.rs
  • airborne_server/src/organisation/application/dimension.rs
  • airborne_server/src/organisation/application/dimension/cohort.rs
  • airborne_server/src/organisation/application/properties.rs
  • airborne_server/src/organisation/application/types.rs
  • airborne_server/src/organisation/application/user.rs
  • airborne_server/src/organisation/application/user/transaction.rs
  • airborne_server/src/organisation/application/user/types.rs
  • airborne_server/src/organisation/application/user/utils.rs
  • airborne_server/src/organisation/transaction.rs
  • airborne_server/src/organisation/types.rs
  • airborne_server/src/organisation/user.rs
  • airborne_server/src/organisation/user/transaction.rs
  • airborne_server/src/organisation/user/types.rs
  • airborne_server/src/organisation/user/utils.rs
  • airborne_server/src/package.rs
  • airborne_server/src/provider.rs
  • airborne_server/src/provider/authn.rs
  • airborne_server/src/provider/authn/auth0.rs
  • airborne_server/src/provider/authn/keycloak.rs
  • airborne_server/src/provider/authn/oidc.rs
  • airborne_server/src/provider/authn/okta.rs
  • airborne_server/src/provider/authz.rs
  • airborne_server/src/provider/authz/casbin.rs
  • airborne_server/src/provider/authz/migration.rs
  • airborne_server/src/provider/authz/permission.rs
  • airborne_server/src/release.rs
  • airborne_server/src/service_account.rs
  • airborne_server/src/service_account/types.rs
  • airborne_server/src/token.rs
  • airborne_server/src/types.rs
  • airborne_server/src/user.rs
  • airborne_server/src/user/types.rs
  • airborne_server/src/utils.rs
  • airborne_server/src/utils/db/models.rs
  • airborne_server/src/utils/db/schema.rs
  • airborne_server/src/utils/keycloak.rs
  • airborne_server/src/utils/transaction_manager.rs
  • memory-bank/techContext.md
💤 Files with no reviewable changes (9)
  • airborne_server/src/utils.rs
  • airborne_server/src/organisation/application/types.rs
  • airborne_server/src/organisation/types.rs
  • airborne_server/src/organisation/transaction.rs
  • airborne_server/src/organisation/application/user/transaction.rs
  • airborne_server/src/organisation/application/user/utils.rs
  • airborne_server/src/utils/transaction_manager.rs
  • airborne_server/src/organisation/user/utils.rs
  • airborne_server/src/organisation/user/transaction.rs

Comment on lines +71 to +77
const { token, org, app } = useAppContext();
const permissions = usePagePermissions(PAGE_AUTHZ);
const { toast } = useToast();
const canManageCohorts =
permissions.can("update_cohort") ||
permissions.can("create_cohort_group") ||
permissions.can("update_cohort_group");
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Split read, checkpoint, and group capabilities instead of using one canManageCohorts flag.

This boolean now gates checkpoint creation, group creation, and group-priority editing even though those actions map to different permissions, so users can see controls that will only 403. The new read permissions are also never used to stop the loaders, so callers without read access still fire the endpoints and can fall into the misleading empty-state path. Please model canRead*, canCreateCheckpoint, canCreateGroup, and canUpdateGroupPriority separately.

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

In `@airborne_dashboard/app/dashboard/`[orgId]/[appId]/cohorts/page.tsx around
lines 71 - 77, The current single canManageCohorts boolean (derived from
permissions.can in the page using usePagePermissions(PAGE_AUTHZ)) conflates
distinct permissions and leads to showing controls and firing loaders for
actions the user cannot perform; replace it with separate flags such as
canReadCohorts/canReadCheckpoints (for gating data loaders and preventing API
calls when read access is absent), canCreateCheckpoint (for showing/enabling
checkpoint creation UI), canCreateGroup (for group creation UI), and
canUpdateGroupPriority (for editing group priority). Update all places that
reference canManageCohorts in this file (including the loaders, conditional UI
rendering, and action handlers) to use the appropriate new boolean so reads
block loaders when absent and each control is only visible/enabled when its
specific permission (via permissions.can("...")) is true.

Comment on lines +35 to +47
const PAGE_AUTHZ = definePagePermissions({
read_packages: permission("package", "read", "app"),
create_package: permission("package", "create", "app"),
});

export default function PackagesPage() {
const [searchQuery, setSearchQuery] = useState("");
const [page, setPage] = useState(1);
const count = 10;
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
const { token, org, app, getAppAccess, getOrgAccess } = useAppContext();
const { token, org, app } = useAppContext();
const permissions = usePagePermissions(PAGE_AUTHZ);
const canCreatePackage = permissions.can("create_package");
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

read_packages currently has no effect.

This page only consumes create_package, so users without package read access still navigate here and fire /packages/list. If the API rejects them, the screen falls through to the normal empty-state flow instead of a denial. Gate the page on read_packages before starting SWR.

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

In `@airborne_dashboard/app/dashboard/`[orgId]/[appId]/packages/page.tsx around
lines 35 - 47, The page registers a read_packages permission but never uses it,
so data fetching (SWR) can run for users without read access; update
PackagesPage to check permissions.can("read_packages") (from
usePagePermissions(PAGE_AUTHZ)) before initiating any SWR/fetch hooks or
rendering fetch-dependent UI—if the check fails, return an
access-denied/unauthorized UI or redirect immediately (short-circuit the
component) so /packages/list is never called for unauthorized users while
keeping canCreatePackage logic intact.

Comment on lines +33 to +41
if (!permissions.isReady) {
return (
<div className="p-6 flex items-center justify-center min-h-screen">
<p className="text-muted-foreground">Checking access...</p>
</div>
);
}

const hasAccess = hasAppAccess(getOrgAccess(org), getAppAccess(org, params.appId), "write");
const hasAccess = permissions.can("read_release") && permissions.can("create_release");
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Gate the release fetch on the permission verdict.

useSWR is initialized before this guard runs, so users without read_release/create_release can still hit /releases/:id while permissions resolve and only then fall into notFound(). Fold permissions.isReady && hasAccess into shouldFetch so the data request only starts after authz succeeds.

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

In
`@airborne_dashboard/app/dashboard/`[orgId]/[appId]/releases/[releaseId]/clone/page.tsx
around lines 33 - 41, The SWR fetch is starting before the permission verdict,
allowing unauthorized requests; update the useSWR initialization to include
permissions.isReady && hasAccess as part of its shouldFetch condition (i.e.,
only call useSWR or pass a non-null key when permissions.isReady && hasAccess is
true). Locate the permissions.isReady / hasAccess logic and the useSWR call in
page.tsx and fold the combined check (permissions.isReady && hasAccess) into the
SWR key/shouldFetch so the release fetch only fires after authorization; keep
the existing notFound() behavior for when access is denied.

Comment on lines +51 to +54
const PAGE_AUTHZ = definePagePermissions({
read_release: permission("release", "read", "app"),
update_release: permission("release", "update", "app"),
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Split Clone Release out from the update_release gate.

The clone CTA routes into a release-creation flow, but this page never requests create_release. Update-only users now get a create entry point, and create-only users lose clone.

Minimal direction
 const PAGE_AUTHZ = definePagePermissions({
   read_release: permission("release", "read", "app"),
   update_release: permission("release", "update", "app"),
+  create_release: permission("release", "create", "app"),
 });

Then gate the clone button with permissions.can("create_release") separately from the update-only lifecycle actions.

Also applies to: 333-380

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

In
`@airborne_dashboard/app/dashboard/`[orgId]/[appId]/releases/[releaseId]/page.tsx
around lines 51 - 54, PAGE_AUTHZ currently only defines read_release and
update_release, but the Clone Release CTA needs create_release and must be gated
separately; add create_release to definePagePermissions (e.g.,
permission("release","create","app")) and update the UI logic so the Clone
button checks permissions.can("create_release") independently from the
update_release gates used for update lifecycle actions (look for the Clone
button render and the update-related action handlers).

Comment on lines +126 to +127
const { token, org, app, setOrg, setApp } = useAppContext();
const permissions = usePagePermissions(PAGE_AUTHZ);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Block on authz resolution before rendering the release body.

This page only denies access after permissions.isReady, but it still falls through to toServeReleaseConfig(data) and the rest of the UI while the permission request is unresolved. A failed permission lookup is also treated as notFound() instead of an error state.

Suggested guard
-  if (isLoading) return <div>Loading...</div>;
-  if (permissions.isReady && !permissions.can("read_release")) {
+  if (isLoading || !permissions.isReady) return <div>Loading...</div>;
+  if (permissions.error) {
+    return <div>Failed to verify access.</div>;
+  }
+  if (!permissions.can("read_release")) {
     notFound();
   }

Based on learnings from airborne_dashboard/hooks/use-page-permissions.ts:1-70, isReady becomes true when the permission request errors, and can() falls back to false.

Also applies to: 156-159

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

In
`@airborne_dashboard/app/dashboard/`[orgId]/[appId]/releases/[releaseId]/page.tsx
around lines 126 - 127, The page currently proceeds to compute and render
release UI before permission resolution; update the component to wait for
usePagePermissions(PAGE_AUTHZ) to finish by checking permissions.isReady before
calling toServeReleaseConfig(data) or rendering the release body, and handle
permission fetch errors separately (show an error state when the permission
request errored rather than treating it as notFound); use permissions.can(...)
only after isReady is true to decide access, and if isReady is true but can(...)
is false call notFound() (or render an access-denied UI) so failed lookups
(where isReady becomes true with an error) surface as an error state instead of
silently proceeding.

Comment on lines 43 to +47
useEffect(() => {
if (token) router.replace("/dashboard");
}, [token]);
if (!config) return;
if (!registrationEnabled) router.replace("/login");
}, [token, config, registrationEnabled, router]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Return after sending authenticated users to /dashboard.

When token is set and registration is disabled, this effect calls router.replace("/dashboard") and then immediately calls router.replace("/login"). Logged-in users can end up bounced back to the login page.

Suggested fix
 useEffect(() => {
-  if (token) router.replace("/dashboard");
+  if (token) {
+    router.replace("/dashboard");
+    return;
+  }
   if (!config) return;
   if (!registrationEnabled) router.replace("/login");
 }, [token, config, registrationEnabled, router]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
if (token) router.replace("/dashboard");
}, [token]);
if (!config) return;
if (!registrationEnabled) router.replace("/login");
}, [token, config, registrationEnabled, router]);
useEffect(() => {
if (token) {
router.replace("/dashboard");
return;
}
if (!config) return;
if (!registrationEnabled) router.replace("/login");
}, [token, config, registrationEnabled, router]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@airborne_dashboard/app/register/page.tsx` around lines 43 - 47, The effect
currently calls router.replace("/dashboard") when token exists but continues and
may call router.replace("/login") if registrationEnabled is false; update the
useEffect in page.tsx so that after detecting token and calling
router.replace("/dashboard") you immediately return (or otherwise short-circuit)
to prevent subsequent checks; locate the useEffect that references token,
config, registrationEnabled and router and add the early return after the
dashboard redirect to stop falling through to the login redirect.

Comment on lines +1 to +34
CREATE TABLE IF NOT EXISTS hyperotaserver.authz_role_bindings (
scope TEXT NOT NULL CHECK (scope IN ('org', 'app')),
role_key TEXT NOT NULL,
resource TEXT NOT NULL,
action TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (scope, role_key, resource, action)
);

CREATE INDEX IF NOT EXISTS authz_role_bindings_scope_resource_action_idx
ON hyperotaserver.authz_role_bindings (scope, resource, action);

CREATE INDEX IF NOT EXISTS authz_role_bindings_scope_role_key_idx
ON hyperotaserver.authz_role_bindings (scope, role_key);

CREATE TABLE IF NOT EXISTS hyperotaserver.authz_memberships (
subject TEXT NOT NULL,
scope TEXT NOT NULL CHECK (scope IN ('org', 'app')),
organisation TEXT NOT NULL,
application TEXT NOT NULL,
role_key TEXT NOT NULL,
role_level INT4 NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (subject, scope, organisation, application)
);

CREATE INDEX IF NOT EXISTS authz_memberships_scope_org_app_idx
ON hyperotaserver.authz_memberships (scope, organisation, application);

CREATE INDEX IF NOT EXISTS authz_memberships_subject_scope_org_idx
ON hyperotaserver.authz_memberships (subject, scope, organisation);

CREATE INDEX IF NOT EXISTS authz_memberships_scope_org_role_idx
ON hyperotaserver.authz_memberships (scope, organisation, role_key);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid IF NOT EXISTS in versioned migrations.

If these tables or indexes already exist with the wrong definition, this migration will silently pass and leave schema drift that no longer matches airborne_server/src/utils/db/models.rs:195-239. Versioned migrations should usually fail loudly here, or explicitly assert the expected shape, instead of skipping creation.

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

In
`@airborne_server/migrations/20260409193000_add_authz_bindings_and_memberships/up.sql`
around lines 1 - 34, Remove the silent "IF NOT EXISTS" behavior and make the
migration fail if objects already exist or assert their exact shape: replace
CREATE TABLE IF NOT EXISTS hyperotaserver.authz_role_bindings and CREATE TABLE
IF NOT EXISTS hyperotaserver.authz_memberships with plain CREATE TABLE so
Postgres raises an error if the table exists, and do the same for the three
CREATE INDEX IF NOT EXISTS statements
(authz_role_bindings_scope_resource_action_idx,
authz_role_bindings_scope_role_key_idx, authz_memberships_scope_org_app_idx,
authz_memberships_subject_scope_org_idx, authz_memberships_scope_org_role_idx);
alternatively, if you must support existing objects, add explicit runtime checks
that validate column names, types, constraints and index definitions against the
expected model (the schema implied by authz_role_bindings and authz_memberships
and referenced in airborne_server/src/utils/db/models.rs) and fail the migration
with a clear error if they differ.


state
.authz_provider
.create_application(state.get_ref(), &organisation, &application, sub)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Use the canonical authz subject here, not the raw JWT sub.

create_application() seeds the creator’s initial application policy. Passing the raw sub claim here breaks the authz subject contract and can leave the creator without access to the app they just created. Please pass the same email-based subject the authz layer uses for policy lookups. Based on learnings: In airborne_server/src/provider/authz.rs, the subject_from_claims() default implementation intentionally uses the email claim as the canonical authorization subject for Casbin policy lookups.

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

In `@airborne_server/src/organisation/application.rs` at line 166, The call to
create_application(...) is passing the raw JWT sub claim which breaks the authz
subject contract; instead obtain and pass the canonical email-based subject used
by the authz layer (the same value returned by authz::subject_from_claims or its
default subject_from_claims implementation) so the creator’s initial application
policy is seeded under the correct subject for Casbin lookups; replace use of
the raw sub variable with the result of subject_from_claims(claims) (or the
equivalent email-based subject extraction) when calling create_application.

Comment on lines 169 to +218
let mut conn = state.db_pool.get()?;

// Get Keycloak Admin Token
let client = reqwest::Client::new();
let admin_token = get_token(state.env.clone(), client).await.map_err(|e| {
info!("Error retrieving Keycloak admin token: {:?}", e);
ABError::Unauthorized(format!("Error retrieving Keycloak admin token: {}", e))
})?;
info!("Admin token retrieved successfully.");
let client = reqwest::Client::new();
let admin = KeycloakAdmin::new(&state.env.keycloak_url.clone(), admin_token, client);
let realm = state.env.realm.clone();

let groups = admin
.realm_groups_get(
&realm,
None,
Some(true), // Exact Match
None,
Some(2), // Check only one group; Should be 5xx if more than 1
Some(false),
None,
Some(organisation.clone()),
)
let new_workspace_name = NewWorkspaceName {
organization_id: &organisation,
application_id: &application,
workspace_name: "pending",
};

let superposition_org_id_from_env = state.env.superposition_org_id.clone();
let mut inserted_workspace: WorkspaceName = diesel::insert_into(workspace_names::table)
.values(&new_workspace_name)
.get_result(&mut conn)
.map_err(|e| {
ABError::InternalServerError(format!("Failed to store workspace name: {}", e))
})?;

let generated_id = inserted_workspace.id;
let generated_workspace_name = format!("workspace{}", generated_id);
inserted_workspace.workspace_name = generated_workspace_name.clone();

diesel::update(workspace_names::table.filter(workspace_names::id.eq(generated_id)))
.set(workspace_names::workspace_name.eq(&generated_workspace_name))
.execute(&mut conn)
.map_err(|e| {
ABError::InternalServerError(format!("Failed to update workspace name: {}", e))
})?;

state
.superposition_client
.create_workspace()
.org_id(superposition_org_id_from_env.clone())
.workspace_name(generated_workspace_name.clone())
.workspace_status(WorkspaceStatus::Enabled)
.allow_experiment_self_approval(true)
.workspace_admin_email("pp-sdk@juspay.in".to_string())
.send()
.await
.map_err(|e| ABError::InternalServerError(format!("{}", e)))?;

if groups.is_empty() {
Err(ABError::NotFound(format!(
"Organisation '{}' not found in Keycloak",
organisation
)))
}
// It is possible that application group comes up in this query; Change to path
// else if groups.len() != 1 {
// return Err(error::ErrorInternalServerError(Json(json!({"Error" : "Inconsistant database entries"}))));
// }
else {
// Reject if application already exists
if groups[0]
.sub_groups
.clone()
.unwrap_or_default()
.iter()
.any(|g| g.name == Some(application.clone()))
{
return Err(ABError::BadRequest(format!(
"Application '{}' already exists in organisation '{}'",
application, organisation
)));
}

// Step 1: Create application group in Keycloak
let parent_group_id = match admin
.realm_groups_with_group_id_children_post(
&realm,
&groups[0].id.clone().unwrap_or_default().clone(),
GroupRepresentation {
name: Some(application.clone()),
..Default::default()
},
)
.await
{
Ok(id) => {
let group_id = id.unwrap_or_default();
// Record this resource in the transaction
transaction.add_keycloak_group(&group_id);
info!("Created application group with ID: {}", group_id);
group_id
}
Err(e) => {
// No rollback needed yet - this is the first operation
return Err(ABError::InternalServerError(format!(
"Failed to create application group: {}",
e
)));
}
};

// Step 2: Create role groups and add user to them
let roles = ["read", "write", "admin"];
for role in roles {
match admin
.realm_groups_with_group_id_children_post(
&realm,
&parent_group_id,
GroupRepresentation {
name: Some(role.to_string()),
..Default::default()
},
)
.await
{
Ok(id) => {
let role_group_id = id.unwrap_or_default();
// Record this resource in the transaction
transaction.add_keycloak_group(&role_group_id);
info!("Created role group {} with ID: {}", role, role_group_id);

// Add the user to the role-specific group
match admin
.realm_users_with_user_id_groups_with_group_id_put(
&realm,
sub,
&role_group_id,
)
.await
{
Ok(_) => {
// Record this user-group relationship in the transaction
transaction.add_keycloak_resource(
"user_group_membership",
&format!("{}:{}", sub, role_group_id),
);
info!("Added user to role group: {}", role);
}
Err(e) => {
// Handle rollback and return error
if let Err(rollback_err) = transaction
.handle_rollback_if_needed(&admin, &realm, &state)
.await
{
info!("Rollback failed: {}", rollback_err);
}

return Err(ABError::InternalServerError(format!(
"Failed to add user to role group {}: {}",
role, e
)));
}
}
}
Err(e) => {
// Handle rollback and return error
if let Err(rollback_err) = transaction
.handle_rollback_if_needed(&admin, &realm, &state)
.await
{
info!("Rollback failed: {}", rollback_err);
}

return Err(ABError::InternalServerError(format!(
"Failed to create role group {}: {}",
role, e
)));
}
}
}

// Store workspace name in our database with a placeholder, then update to "workspace{id}"
let new_workspace_name = NewWorkspaceName {
organization_id: &organisation,
application_id: &application,
workspace_name: "pending",
};

let superposition_org_id_from_env = state.env.superposition_org_id.clone();
info!(
"Using Superposition Org ID from environment: {}",
superposition_org_id_from_env
);
// Insert and get the inserted row (to get the id)
let mut inserted_workspace: WorkspaceName = diesel::insert_into(workspace_names::table)
.values(&new_workspace_name)
.get_result(&mut conn)
.map_err(|e| {
ABError::InternalServerError(format!("Failed to store workspace name: {}", e))
})?;

let generated_id = inserted_workspace.id;
let generated_workspace_name = format!("workspace{}", generated_id);
inserted_workspace.workspace_name = generated_workspace_name.clone();

// Update the workspace_name to "workspace{id}"
diesel::update(workspace_names::table.filter(workspace_names::id.eq(generated_id)))
.set(workspace_names::workspace_name.eq(&generated_workspace_name))
.execute(&mut conn)
.map_err(|e| {
ABError::InternalServerError(format!("Failed to update workspace name: {}", e))
})?;

// Step 4: Create workspace in Superposition

match state
.superposition_client
.create_workspace()
.org_id(superposition_org_id_from_env.clone())
.workspace_name(generated_workspace_name.clone())
.workspace_status(WorkspaceStatus::Enabled)
.allow_experiment_self_approval(true)
.workspace_admin_email("pp-sdk@juspay.in".to_string())
.send()
.await
{
Ok(workspace) => {
// Record Superposition resource using workspace name as the ID
transaction.set_superposition_resource(&workspace.workspace_name);
info!("Created workspace in Superposition: {:?}", workspace);
workspace
}
Err(e) => {
// Handle rollback and return error
if let Err(rollback_err) = transaction
.handle_rollback_if_needed(&admin, &realm, &state)
.await
{
info!("Rollback failed: {}", rollback_err);
}

return Err(ABError::InternalServerError(format!(
"Failed to create workspace in Superposition: {}",
e
)));
}
};

// Helper function to create default config with error handling
async fn create_config_with_tx<E>(
create_fn: impl futures::Future<Output = Result<(), E>>,
key: &str,
transaction: &TransactionManager,
admin: &KeycloakAdmin,
realm: &str,
state: &web::Data<AppState>,
) -> Result<(), ABError>
where
E: std::fmt::Display,
{
match create_fn.await {
Ok(result) => {
info!("Created configuration for key: {}", key);
Ok(result)
}
Err(e) => {
// Handle rollback
if let Err(rollback_err) = transaction
.handle_rollback_if_needed(admin, realm, state)
.await
{
info!("Rollback failed: {}", rollback_err);
}

Err(ABError::InternalServerError(format!(
"Failed to create configuration for {}: {}",
key, e
)))
}
}
}

create_config_with_tx(
async {
migrate_superposition_workspace(
&inserted_workspace,
&state,
&SuperpositionMigrationStrategy::Patch,
)
.await
.map_err(|e| {
ABError::InternalServerError(format!("Workspace migration error: {}", e))
})
},
"migrate_superposition_workspace",
&transaction,
&admin,
&realm,
&state,
)
.await?;

// Mark transaction as complete since all operations have succeeded
transaction.set_database_inserted();

Ok(Json(Application {
application,
organisation,
access: roles.iter().map(|&s| s.to_string()).collect(),
}))
}
.map_err(|e| {
ABError::InternalServerError(format!(
"Failed to create workspace in Superposition: {}",
e
))
})?;

migrate_superposition_workspace(
&inserted_workspace,
&state,
&SuperpositionMigrationStrategy::Patch,
)
.await
.map_err(|e| ABError::InternalServerError(format!("Workspace migration error: {}", e)))?;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Handle partial provisioning failures before returning.

After the authz provider call succeeds, this path mutates workspace_names, Superposition workspace creation, and workspace migration with no compensation. If any later step fails, retries can hit an already-created app in authz while the DB row or workspace is missing/half-initialized. Please add best-effort rollback or persist a recoverable failed state instead of leaving the systems diverged.

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

In `@airborne_server/src/organisation/application.rs` around lines 169 - 218, The
code creates a DB row (inserted_workspace), updates it, calls
state.superposition_client.create_workspace() and then
migrate_superposition_workspace() with no compensating actions on later failure;
add a best-effort rollback or durable failed marker: if create_workspace() or
migrate_superposition_workspace(...) returns an error, attempt to delete or mark
the workspace_names row (workspace_names::table filtered by
workspace_names::id.eq(generated_id)) and also attempt to delete the
Superposition workspace via state.superposition_client (using the same org_id
and generated_workspace_name) before returning the ABError; if compensating
deletes fail, persist a recoverable failed state on the DB row (e.g. set a
workspace_status/failure_reason column or set workspace_name to a "failed-..."
sentinel) so retries can detect/infer partial provisioning, and ensure all calls
reference inserted_workspace, generated_id, generated_workspace_name,
state.superposition_client.create_workspace(), and
migrate_superposition_workspace() so the rollback paths target the same
resources.

Comment thread Makefile
Comment on lines 488 to +489
USE_ENCRYPTED_SECRETS=$$ENCRYPTION_MODE $(MAKE) localstack-init; \
USE_ENCRYPTED_SECRETS=$$ENCRYPTION_MODE $(MAKE) keycloak-init; \
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Restore init dependency order in run target.

Line 488 currently runs localstack-init before keycloak-init, but airborne_server/scripts/init-localstack.sh reads auth secrets from .env.generated produced by airborne_server/scripts/init-keycloak.sh. This can encrypt empty/stale values and break auth bootstrapping.

🔧 Suggested fix
  USE_ENCRYPTED_SECRETS=$$ENCRYPTION_MODE $(MAKE) superposition-init; \
- USE_ENCRYPTED_SECRETS=$$ENCRYPTION_MODE $(MAKE) localstack-init; \
- USE_ENCRYPTED_SECRETS=$$ENCRYPTION_MODE $(MAKE) keycloak-init; \
+ USE_ENCRYPTED_SECRETS=$$ENCRYPTION_MODE $(MAKE) keycloak-init; \
+ USE_ENCRYPTED_SECRETS=$$ENCRYPTION_MODE $(MAKE) localstack-init; \
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
USE_ENCRYPTED_SECRETS=$$ENCRYPTION_MODE $(MAKE) localstack-init; \
USE_ENCRYPTED_SECRETS=$$ENCRYPTION_MODE $(MAKE) keycloak-init; \
USE_ENCRYPTED_SECRETS=$$ENCRYPTION_MODE $(MAKE) keycloak-init; \
USE_ENCRYPTED_SECRETS=$$ENCRYPTION_MODE $(MAKE) localstack-init; \
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Makefile` around lines 488 - 489, The run target in the Makefile runs
localstack-init before keycloak-init which causes localstack to read stale/empty
auth secrets; change the invocation order so
USE_ENCRYPTED_SECRETS=$$ENCRYPTION_MODE $(MAKE) keycloak-init runs before
USE_ENCRYPTED_SECRETS=$$ENCRYPTION_MODE $(MAKE) localstack-init (i.e., swap the
two recipe invocations under the run target) so
airborne_server/scripts/init-keycloak.sh generates .env.generated before
airborne_server/scripts/init-localstack.sh consumes it.

feat: update to use oidc crate

fix: google signin issues

fix: PKCE
fix: casbin rbac

fix: leeway config for clock drift

fix: user profile in keycloak signup

fix: lint

fix: refactor

fix: isues

macro based enforcement

frontend authz checks

fix: frontend lint

fix: added crate description

fix: lint

fix: gated unbounded vec length

fix: coderabbit

fix: lint

feat: keycloak to casbin driven by env

fix: lint

fix: db pool
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.

2 participants