diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md index cf801f2a..edf72ee2 100644 --- a/API_DOCUMENTATION.md +++ b/API_DOCUMENTATION.md @@ -17,6 +17,8 @@ x-organisation: x-application: ``` +AuthZ subject resolution is email-based. Tokens missing an email claim are rejected on protected routes. + --- ## 1. Authentication Endpoints @@ -25,6 +27,7 @@ x-application: **Endpoint:** `POST /users/login` **Description:** Authenticates a user and returns a JWT token along with user details. +When the configured AuthN provider does not support password login, this endpoint returns `400 Bad Request`. **Headers:** ```json @@ -125,6 +128,7 @@ x-application: **Endpoint:** `POST /users/create` **Description:** Creates a new user account. +Registration is supported only when `AUTHN_PROVIDER=keycloak`; otherwise this endpoint returns `400 Bad Request`. **Headers:** ```json @@ -145,9 +149,21 @@ x-application: "password": { "type": "string", "description": "User password" + }, + "first_name": { + "type": "string", + "description": "User first name (required for Keycloak signup)" + }, + "last_name": { + "type": "string", + "description": "User last name (required for Keycloak signup)" + }, + "email": { + "type": "string", + "description": "User email (required for Keycloak signup)" } }, - "required": ["name", "password"] + "required": ["name", "password", "first_name", "last_name", "email"] } ``` @@ -156,7 +172,11 @@ x-application: ### 1.3 Get OAuth URL **Endpoint:** `GET /users/oauth/url` -**Description:** Gets the OAuth URL for Google Sign-in (if enabled). +**Description:** Gets the OIDC/OAuth authorization URL for the configured AuthN provider. + +**Query Parameters:** +- `offline` (optional, boolean): when `true`, requests offline access (refresh token flow for PAT generation). +- `idp` (optional, string): OIDC provider hint (for Keycloak this maps to `kc_idp_hint`, for example `google` or `github`). **Headers:** ```json @@ -172,7 +192,7 @@ x-application: "properties": { "auth_url": { "type": "string", - "description": "Google OAuth authorization URL" + "description": "OIDC/OAuth authorization URL" }, "state": { "type": "string", @@ -185,7 +205,7 @@ x-application: ### 1.4 OAuth Login **Endpoint:** `POST /users/oauth/login` -**Description:** Completes OAuth login flow with authorization code. +**Description:** Completes OIDC/OAuth login flow with authorization code. **Headers:** ```json @@ -217,7 +237,8 @@ x-application: ### 1.5 OAuth Signup **Endpoint:** `POST /users/oauth/signup` -**Description:** Completes OAuth signup flow with authorization code. +**Description:** Completes OIDC/OAuth signup flow with authorization code. +Signup is provider-capability gated and in v1 is supported only for `AUTHN_PROVIDER=keycloak`. **Headers:** ```json @@ -540,7 +561,7 @@ x-application: "properties": { "user": { "type": "string", - "description": "Username to add" + "description": "User email to add" }, "access": { "type": "string", @@ -588,7 +609,7 @@ x-application: "properties": { "user": { "type": "string", - "description": "Username to update" + "description": "User email to update" }, "access": { "type": "string", @@ -636,7 +657,7 @@ x-application: "properties": { "user": { "type": "string", - "description": "Username to remove" + "description": "User email to remove" } }, "required": ["user"] @@ -659,7 +680,7 @@ x-application: ``` ### 4.4 List Organisation Users -**Endpoint:** `GET /organisation/user` +**Endpoint:** `GET /organisation/user/list` **Description:** Lists all users in an organisation with their access levels. @@ -682,11 +703,17 @@ x-application: "items": { "type": "object", "properties": { - "user": { + "username": { "type": "string" }, - "access": { - "type": "string" + "email": { + "type": ["string", "null"] + }, + "roles": { + "type": "array", + "items": { + "type": "string" + } } } } @@ -2319,7 +2346,7 @@ All endpoints may return the following error responses: 6. **Search**: File listing supports search functionality via the `search` query parameter. -7. **OAuth**: Google OAuth endpoints are only available when `ENABLE_GOOGLE_SIGNIN` is set to `true`. +7. **OAuth**: OIDC OAuth endpoints are available when OIDC login is enabled; Keycloak IdP options are controlled by `OIDC_ENABLED_IDPS`. 8. **Organization Creation**: The `/organisations/request` endpoint is used when `ORGANISATION_CREATION_DISABLED` is `true`. diff --git a/Cargo.lock b/Cargo.lock index 2064a722..eebb0f43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -302,6 +302,20 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if 1.0.3", + "const-random", + "getrandom 0.3.3", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -338,6 +352,15 @@ dependencies = [ "uuid", ] +[[package]] +name = "airborne_authz_macros" +version = "0.31.1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "airborne_server" version = "0.33.0" @@ -346,6 +369,8 @@ dependencies = [ "actix-multipart", "actix-web", "aes-gcm", + "airborne_authz_macros", + "async-trait", "aws-config", "aws-sdk-cloudfront", "aws-sdk-kms", @@ -354,8 +379,10 @@ dependencies = [ "aws-smithy-types", "base64 0.22.1", "bytes", + "casbin", "chrono", "diesel", + "diesel-adapter", "diesel_migrations", "dotenv", "futures", @@ -364,9 +391,11 @@ dependencies = [ "hex", "http 0.2.12", "http-body 1.0.1", + "inventory", "jsonwebtoken", "keycloak", "log", + "openidconnect", "r2d2", "reqwest", "rustls 0.23.31", @@ -709,7 +738,7 @@ dependencies = [ "hmac", "http 0.2.12", "http 1.3.1", - "p256", + "p256 0.11.1", "percent-encoding", "ring", "sha2", @@ -1006,6 +1035,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.21.7" @@ -1043,7 +1078,7 @@ dependencies = [ "bitflags 2.9.4", "cexpr", "clang-sys", - "itertools", + "itertools 0.13.0", "log", "prettyplease", "proc-macro2", @@ -1151,6 +1186,28 @@ dependencies = [ "libbz2-rs-sys", ] +[[package]] +name = "casbin" +version = "2.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c53f7476c2d0d9cd7ccc88c16ffc5c7889a0497b3462b10b12b5329adde69665" +dependencies = [ + "async-trait", + "fixedbitset", + "getrandom 0.3.3", + "hashlink", + "once_cell", + "parking_lot", + "petgraph", + "regex", + "rhai", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "wasm-bindgen-test", +] + [[package]] name = "cc" version = "1.2.37" @@ -1184,6 +1241,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.42" @@ -1279,6 +1342,26 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "tiny-keccak", +] + [[package]] name = "constant_time_eq" version = "0.3.1" @@ -1389,6 +1472,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-bigint" version = "0.4.9" @@ -1407,8 +1496,10 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ + "generic-array", "rand_core 0.6.4", "subtle", + "zeroize", ] [[package]] @@ -1431,6 +1522,33 @@ dependencies = [ "cipher", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if 1.0.3", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "darling" version = "0.20.11" @@ -1517,6 +1635,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.3" @@ -1589,6 +1718,21 @@ dependencies = [ "uuid", ] +[[package]] +name = "diesel-adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee23d2655b19f59fb932700e0f0a76fc1fe1790dee83566d1037ade88f7f4463" +dependencies = [ + "async-trait", + "casbin", + "diesel", + "futures", + "libsqlite3-sys", + "once_cell", + "tokio", +] + [[package]] name = "diesel_derives" version = "2.2.7" @@ -1629,6 +1773,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -1694,10 +1839,48 @@ version = "0.14.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" dependencies = [ - "der", - "elliptic-curve", - "rfc6979", - "signature", + "der 0.6.1", + "elliptic-curve 0.12.3", + "rfc6979 0.3.1", + "signature 1.6.4", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest", + "elliptic-curve 0.13.8", + "rfc6979 0.4.0", + "signature 2.2.0", + "spki 0.7.3", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature 2.2.0", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", ] [[package]] @@ -1712,16 +1895,37 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" dependencies = [ - "base16ct", + "base16ct 0.1.1", "crypto-bigint 0.4.9", - "der", + "der 0.6.1", + "digest", + "ff 0.12.1", + "generic-array", + "group 0.12.1", + "pkcs8 0.9.0", + "rand_core 0.6.4", + "sec1 0.3.0", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct 0.2.0", + "crypto-bigint 0.5.5", "digest", - "ff", + "ff 0.13.1", "generic-array", - "group", - "pkcs8", + "group 0.13.0", + "hkdf", + "pem-rfc7468", + "pkcs8 0.10.2", "rand_core 0.6.4", - "sec1", + "sec1 0.7.3", "subtle", "zeroize", ] @@ -1767,12 +1971,34 @@ dependencies = [ "subtle", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "flate2" version = "1.1.2" @@ -1923,6 +2149,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1945,9 +2172,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if 1.0.3", + "js-sys", "libc", "r-efi", "wasi 0.14.5+wasi-0.2.4", + "wasm-bindgen", ] [[package]] @@ -1984,7 +2213,7 @@ dependencies = [ "http-body-util", "hyper 1.7.0", "hyper-util", - "itertools", + "itertools 0.13.0", "mime", "percent-encoding", "serde", @@ -2021,7 +2250,18 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" dependencies = [ - "ff", + "ff 0.12.1", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff 0.13.1", "rand_core 0.6.4", "subtle", ] @@ -2070,6 +2310,15 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -2081,6 +2330,15 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -2099,6 +2357,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -2260,6 +2527,7 @@ dependencies = [ "tokio", "tokio-rustls 0.26.2", "tower-service", + "webpki-roots", ] [[package]] @@ -2478,6 +2746,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + [[package]] name = "io-uring" version = "0.7.10" @@ -2505,6 +2782,15 @@ dependencies = [ "serde", ] +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -2580,6 +2866,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin 0.9.8", +] [[package]] name = "libbz2-rs-sys" @@ -2643,6 +2932,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libsqlite3-sys" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afc22eff61b133b115c6e8c74e818c628d6d5e7a502afea6f64dee076dd94326" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "libz-rs-sys" version = "0.5.2" @@ -2718,6 +3024,12 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "lz4_flex" version = "0.11.5" @@ -2792,6 +3104,16 @@ dependencies = [ "unicase", ] +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2842,6 +3164,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" +dependencies = [ + "spin 0.5.2", +] + [[package]] name = "nom" version = "7.1.3" @@ -2871,6 +3202,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -2886,6 +3233,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2893,6 +3251,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2927,19 +3286,42 @@ dependencies = [ ] [[package]] -name = "object" -version = "0.36.7" +name = "oauth2" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "memchr", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + "base64 0.22.1", + "chrono", + "getrandom 0.2.16", + "http 1.3.1", + "rand 0.8.5", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "portable-atomic", +] [[package]] name = "opaque-debug" @@ -2947,6 +3329,37 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openidconnect" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2" +dependencies = [ + "base64 0.21.7", + "chrono", + "dyn-clone", + "ed25519-dalek", + "hmac", + "http 1.3.1", + "itertools 0.10.5", + "log", + "oauth2", + "p256 0.13.2", + "p384", + "rand 0.8.5", + "rsa", + "serde", + "serde-value", + "serde_json", + "serde_path_to_error", + "serde_plain", + "serde_with", + "sha2", + "subtle", + "thiserror 1.0.69", + "url", +] + [[package]] name = "openssl" version = "0.10.73" @@ -2991,6 +3404,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "os_pipe" version = "1.2.2" @@ -3013,8 +3435,32 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" dependencies = [ - "ecdsa", - "elliptic-curve", + "ecdsa 0.14.8", + "elliptic-curve 0.12.3", + "sha2", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder", "sha2", ] @@ -3067,12 +3513,31 @@ dependencies = [ "serde", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap 2.11.1", +] + [[package]] name = "pin-project" version = "1.1.10" @@ -3105,14 +3570,35 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", +] + [[package]] name = "pkcs8" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" dependencies = [ - "der", - "spki", + "der 0.6.1", + "spki 0.6.0", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", ] [[package]] @@ -3133,6 +3619,12 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.3" @@ -3183,6 +3675,15 @@ dependencies = [ "syn", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve 0.13.8", +] + [[package]] name = "proc-macro-crate" version = "3.3.0" @@ -3227,7 +3728,7 @@ dependencies = [ "libc", "procfs", "protobuf", - "spin", + "spin 0.5.2", "thiserror 1.0.69", ] @@ -3237,6 +3738,61 @@ version = "2.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94" +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.31", + "socket2 0.6.0", + "thiserror 2.0.16", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.3", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls 0.23.31", + "rustls-pki-types", + "slab", + "thiserror 2.0.16", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.0", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.40" @@ -3451,6 +4007,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls 0.23.31", "rustls-pki-types", "serde", "serde_json", @@ -3458,6 +4016,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls 0.26.2", "tokio-util", "tower 0.5.2", "tower-http 0.6.6", @@ -3467,6 +4026,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", + "webpki-roots", ] [[package]] @@ -3480,6 +4040,46 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rhai" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f9ef5dabe4c0b43d8f1187dc6beb67b53fe607fff7e30c5eb7f71b814b8c2c1" +dependencies = [ + "ahash", + "bitflags 2.9.4", + "no-std-compat", + "num-traits", + "once_cell", + "rhai_codegen", + "serde", + "smallvec", + "smartstring", + "thin-vec", + "web-time", +] + +[[package]] +name = "rhai_codegen" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4322a2a4e8cf30771dd9f27f7f37ca9ac8fe812dddd811096a98483080dabe6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ring" version = "0.17.14" @@ -3500,6 +4100,26 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "signature 2.2.0", + "spki 0.7.3", + "subtle", + "zeroize", +] + [[package]] name = "rustc-demangle" version = "0.1.26" @@ -3610,6 +4230,7 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ + "web-time", "zeroize", ] @@ -3647,6 +4268,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "sasl2-sys" version = "0.1.22+2.1.28" @@ -3741,10 +4371,24 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" dependencies = [ - "base16ct", - "der", + "base16ct 0.1.1", + "der 0.6.1", + "generic-array", + "pkcs8 0.9.0", + "subtle", + "zeroize", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct 0.2.0", + "der 0.7.10", "generic-array", - "pkcs8", + "pkcs8 0.10.2", "subtle", "zeroize", ] @@ -3801,6 +4445,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -4004,6 +4658,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.7" @@ -4033,6 +4697,21 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "serde", + "static_assertions", + "version_check", +] [[package]] name = "socket2" @@ -4060,6 +4739,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "spki" version = "0.6.0" @@ -4067,7 +4752,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" dependencies = [ "base64ct", - "der", + "der 0.6.1", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", ] [[package]] @@ -4196,6 +4891,15 @@ dependencies = [ "windows-sys 0.61.0", ] +[[package]] +name = "thin-vec" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144f754d318415ac792f9d69fc87abbbfc043ce2ef041c60f16ad828f638717d" +dependencies = [ + "serde", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4277,6 +4981,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.1" @@ -4287,6 +5000,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.47.1" @@ -4714,6 +5442,16 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -4819,6 +5557,30 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-bindgen-test" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80cc7f8a4114fdaa0c58383caf973fc126cf004eba25c9dc639bccd3880d55ad" +dependencies = [ + "js-sys", + "minicov", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5ada2ab788d46d4bda04c9d567702a79c8ced14f51f221646a16ed39d0e6a5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -4842,6 +5604,34 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.0", +] + [[package]] name = "windows-core" version = "0.61.2" diff --git a/Cargo.toml b/Cargo.toml index 7adb381f..b973300a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,11 @@ package.readme = "README.md" package.authors = ["airborne@juspay.in"] package.version = "0.33.0" -members = ["airborne_server", "airborne_analytics_server"] +members = [ + "airborne_server", + "airborne_analytics_server", + "airborne_authz_macros", +] [workspace.dependencies] chrono = { version = "0.4.41" } diff --git a/Makefile b/Makefile index 1f1e0027..4a0e8029 100644 --- a/Makefile +++ b/Makefile @@ -485,8 +485,8 @@ run: kill db superposition keycloak-db keycloak localstack echo "$(YELLOW)📝 Encryption disabled (plaintext mode)$(NC)"; \ fi; \ USE_ENCRYPTED_SECRETS=$$ENCRYPTION_MODE $(MAKE) superposition-init; \ - USE_ENCRYPTED_SECRETS=$$ENCRYPTION_MODE $(MAKE) keycloak-init; \ USE_ENCRYPTED_SECRETS=$$ENCRYPTION_MODE $(MAKE) localstack-init; \ + USE_ENCRYPTED_SECRETS=$$ENCRYPTION_MODE $(MAKE) keycloak-init; \ trap 'kill 0' INT TERM; \ $(MAKE) dashboard & \ $(MAKE) docs & \ diff --git a/README.md b/README.md index a3dbe234..2d3e1c4c 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ For developers who need comprehensive backend solutions to manage updates and an A robust backend system that can manage application versions, store update packages, and deliver them to your SDK-integrated applications. -- **Key functionalities**: User authentication (via Keycloak), organization/application management, package storage, release configurations, and a dashboard UI. +- **Key functionalities**: User authentication (OIDC-based; Keycloak-compatible), organization/application management, package storage, release configurations, and a dashboard UI. - **Technology stack**: Rust (Actix Web), PostgreSQL, Keycloak, Docker, LocalStack (for AWS emulation). #### **[Airborne Analytics Server](airborne_analytics_server/README.md)** @@ -287,16 +287,23 @@ The Airborne server uses the following environment variables. All secrets can be *Required when using real AWS. Not needed for LocalStack. -#### Keycloak Settings +#### AuthN Provider Settings | Variable | Required | Encrypted | Default | Description | |----------|----------|-----------|---------|-------------| -| `KEYCLOAK_URL` | Yes | No | - | Keycloak internal URL | -| `KEYCLOAK_EXTERNAL_URL` | No | No | `localhost:8180` | Keycloak external URL | -| `KEYCLOAK_REALM` | Yes | No | - | Keycloak realm name | -| `KEYCLOAK_CLIENT_ID` | Yes | No | - | Keycloak client ID | -| `KEYCLOAK_SECRET` | Yes | **Yes** | - | Keycloak client secret | -| `KEYCLOAK_PUBLIC_KEY` | Yes | No | - | Keycloak realm public key (for JWT verification) | +| `AUTHN_PROVIDER` | No | No | `keycloak` | AuthN provider (`keycloak`, `oidc`, `okta`, or `auth0`) | +| `OIDC_ISSUER_URL` | Yes | No | - | OIDC issuer URL | +| `OIDC_EXTERNAL_ISSUER_URL` | No | No | - | External OIDC issuer/base URL used for browser redirects (defaults to `OIDC_ISSUER_URL`) | +| `OIDC_CLIENT_ID` | Yes | No | - | OIDC client ID | +| `OIDC_CLIENT_SECRET` | Yes | **Yes** | - | OIDC client secret | +| `AUTH_ADMIN_CLIENT_ID` | Yes | No | - | Client ID used for AuthZ/admin API token acquisition | +| `AUTH_ADMIN_CLIENT_SECRET` | Yes | **Yes** | - | Client secret used for AuthZ/admin API token acquisition | +| `AUTH_ADMIN_TOKEN_URL` | Yes | No | - | OAuth token endpoint for admin API access tokens | +| `AUTH_ADMIN_AUDIENCE` | No | No | - | Optional audience parameter (commonly required by Auth0) | +| `AUTH_ADMIN_SCOPES` | No | No | - | Optional space-separated scopes (commonly required by Okta/Auth0) | +| `AUTH_ADMIN_ISSUER` | Yes | No | - | Issuer URL used to derive Keycloak AuthZ realm/base URL | + +For current Keycloak-backed authorization, `AUTH_ADMIN_ISSUER` must be a Keycloak realm issuer URL (`.../realms/`). #### Superposition Settings @@ -315,7 +322,7 @@ The Airborne server uses the following environment variables. All secrets can be | Variable | Required | Encrypted | Default | Description | |----------|----------|-----------|---------|-------------| -| `ENABLE_GOOGLE_SIGNIN` | No | No | `false` | Enable Google Sign-In for organisation creation | +| `OIDC_ENABLED_IDPS` | No | No | (empty) | Comma-separated OIDC IdP hints for Keycloak OAuth (for example: `google,github`) | | `ORGANISATION_CREATION_DISABLED` | No | No | `false` | Disable organisation creation via API | *When `ORGANISATION_CREATION_DISABLED=true`, the following are required:* @@ -332,7 +339,7 @@ Note: the environment variable is spelled `ORGANISATION_CREATION_DISABLED`. | `GCP_SERVICE_ACCOUNT_PATH` | Conditional | No | - | Path to GCP service account JSON file | | `GOOGLE_SERVICE_ACCOUNT_KEY` | Conditional | **Yes** | - | GCP service account key JSON content | -*Required when `ORGANISATION_CREATION_DISABLED=true` and `ENABLE_GOOGLE_SIGNIN=true`* +*Required when `ORGANISATION_CREATION_DISABLED=true`* #### Public Endpoint diff --git a/airborne_authz_macros/Cargo.toml b/airborne_authz_macros/Cargo.toml new file mode 100644 index 00000000..19e27563 --- /dev/null +++ b/airborne_authz_macros/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "airborne_authz_macros" +description = "Procedural macros for airborne authorization" +version.workspace = true +edition = "2021" +license.workspace = true +repository.workspace = true +homepage.workspace = true +readme.workspace = true +authors.workspace = true + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full", "parsing"] } diff --git a/airborne_authz_macros/src/lib.rs b/airborne_authz_macros/src/lib.rs new file mode 100644 index 00000000..b636ebca --- /dev/null +++ b/airborne_authz_macros/src/lib.rs @@ -0,0 +1,180 @@ +use proc_macro::TokenStream; +use quote::quote; +use syn::{ + parse::Parser, parse_macro_input, Attribute, Expr, ExprArray, ExprLit, ItemFn, Lit, LitBool, + LitStr, +}; + +#[derive(Default)] +struct AuthzArgs { + resource: Option, + action: Option, + allow_org: bool, + allow_app: bool, + org_roles: Vec, + app_roles: Vec, +} + +impl AuthzArgs { + fn parse_role_array(expr: ExprArray) -> Result, syn::Error> { + let mut parsed = Vec::with_capacity(expr.elems.len()); + for entry in expr.elems { + match entry { + Expr::Lit(ExprLit { + lit: Lit::Str(role), + .. + }) => parsed.push(role), + _ => { + return Err(syn::Error::new_spanned( + entry, + "Role list entries must be string literals", + )) + } + } + } + Ok(parsed) + } + + fn parse(input: TokenStream) -> Result { + let mut args = AuthzArgs { + allow_org: true, + allow_app: true, + ..Default::default() + }; + + let parser = syn::meta::parser(|meta| { + if meta.path.is_ident("resource") { + args.resource = Some(meta.value()?.parse()?); + return Ok(()); + } + if meta.path.is_ident("action") { + args.action = Some(meta.value()?.parse()?); + return Ok(()); + } + if meta.path.is_ident("allow_org") { + let value: LitBool = meta.value()?.parse()?; + args.allow_org = value.value; + return Ok(()); + } + if meta.path.is_ident("allow_app") { + let value: LitBool = meta.value()?.parse()?; + args.allow_app = value.value; + return Ok(()); + } + if meta.path.is_ident("org_roles") { + // Supports: org_roles = ["owner", "admin"]. + let roles_expr: ExprArray = meta.value()?.parse()?; + args.org_roles = Self::parse_role_array(roles_expr)?; + return Ok(()); + } + if meta.path.is_ident("app_roles") { + // Supports: app_roles = ["admin", "write"]. + let roles_expr: ExprArray = meta.value()?.parse()?; + args.app_roles = Self::parse_role_array(roles_expr)?; + return Ok(()); + } + + Err(meta.error("Unsupported authz attribute argument")) + }); + + parser.parse(input)?; + + if args.resource.is_none() { + return Err(syn::Error::new( + proc_macro2::Span::call_site(), + "Missing required argument: resource = \"...\"", + )); + } + if args.action.is_none() { + return Err(syn::Error::new( + proc_macro2::Span::call_site(), + "Missing required argument: action = \"...\"", + )); + } + + Ok(args) + } +} + +fn parse_method_and_path(attrs: &[Attribute]) -> (String, String) { + for attr in attrs { + let Some(ident) = attr.path().get_ident() else { + continue; + }; + let method = match ident.to_string().as_str() { + "get" => Some("GET"), + "post" => Some("POST"), + "put" => Some("PUT"), + "patch" => Some("PATCH"), + "delete" => Some("DELETE"), + _ => None, + }; + let Some(method) = method else { + continue; + }; + + let path = match attr.parse_args::() { + Ok(Expr::Lit(ExprLit { + lit: Lit::Str(value), + .. + })) => value.value(), + _ => String::new(), + }; + + return (method.to_string(), path); + } + + ("UNKNOWN".to_string(), String::new()) +} + +#[proc_macro_attribute] +pub fn authz(args: TokenStream, item: TokenStream) -> TokenStream { + let args = match AuthzArgs::parse(args) { + Ok(parsed) => parsed, + Err(error) => return error.to_compile_error().into(), + }; + + let mut function = parse_macro_input!(item as ItemFn); + let original_block = function.block; + + let resource = args.resource.expect("validated above"); + let action = args.action.expect("validated above"); + let allow_org = args.allow_org; + let allow_app = args.allow_app; + let org_roles = args.org_roles; + let app_roles = args.app_roles; + + let (method, path) = parse_method_and_path(&function.attrs); + let method_lit = LitStr::new(&method, proc_macro2::Span::call_site()); + let path_lit = LitStr::new(&path, proc_macro2::Span::call_site()); + + function.block = Box::new(syn::parse_quote!({ + crate::provider::authz::permission::enforce_endpoint_permission( + &state, + &auth_response, + #resource, + #action, + #allow_org, + #allow_app, + ) + .await?; + #original_block + })); + + TokenStream::from(quote! { + ::inventory::submit! { + crate::provider::authz::permission::EndpointPermissionBinding::new( + #method_lit, + #path_lit, + #resource, + #action, + &[#(#org_roles),*], + &[#(#app_roles),*], + #allow_org, + #allow_app, + ) + } + + #function + }) +} diff --git a/airborne_dashboard/app/dashboard/[orgId]/[appId]/cohorts/page.tsx b/airborne_dashboard/app/dashboard/[orgId]/[appId]/cohorts/page.tsx index 078d1ab1..a5e63a8c 100644 --- a/airborne_dashboard/app/dashboard/[orgId]/[appId]/cohorts/page.tsx +++ b/airborne_dashboard/app/dashboard/[orgId]/[appId]/cohorts/page.tsx @@ -14,7 +14,18 @@ import { useAppContext } from "@/providers/app-context"; import { apiFetch } from "@/lib/api"; import { useToast } from "@/hooks/use-toast"; import Link from "next/link"; -import { hasAppAccess } from "@/lib/utils"; +import { definePagePermissions, permission } from "@/lib/page-permissions"; +import { usePagePermissions } from "@/hooks/use-page-permissions"; + +const PAGE_AUTHZ = definePagePermissions({ + read_dimensions: permission("dimension", "read", "app"), + read_cohort: permission("cohort", "read", "app"), + update_cohort: permission("cohort", "update", "app"), + create_cohort_group: permission("cohort_group", "create", "app"), + read_cohort_group: permission("cohort_group", "read", "app"), + update_cohort_group: permission("cohort_group", "update", "app"), + read_releases: permission("release", "read", "app"), +}); interface CohortSchema { type: string; @@ -57,8 +68,13 @@ type GroupForm = { }; export default function CohortsPage() { - const { token, org, app, getAppAccess, getOrgAccess } = useAppContext(); + 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"); // State const [dimensions, setDimensions] = useState([]); @@ -415,7 +431,7 @@ export default function CohortsPage() { Version Checkpoints Segment users based on version comparisons - {hasAppAccess(getOrgAccess(org), getAppAccess(org, app)) && ( + {canManageCohorts && ( @@ -335,10 +345,7 @@ export default function DimensionsPage() { size="sm" className="h-4 w-4 p-0" onClick={() => movePriority(d.dimension, d.position + 1)} - disabled={ - d.position === dimensions.length - 1 || - !hasAppAccess(getOrgAccess(org), getAppAccess(org, app)) - } + disabled={d.position === dimensions.length - 1 || !canUpdateDimensions} > diff --git a/airborne_dashboard/app/dashboard/[orgId]/[appId]/files/page.tsx b/airborne_dashboard/app/dashboard/[orgId]/[appId]/files/page.tsx index 348d18ef..174c68ad 100644 --- a/airborne_dashboard/app/dashboard/[orgId]/[appId]/files/page.tsx +++ b/airborne_dashboard/app/dashboard/[orgId]/[appId]/files/page.tsx @@ -20,7 +20,6 @@ import { Search, ChevronDown, ChevronRight, File, Filter, Plus, Loader2, Pencil import { FileCreationModal } from "@/components/file-creation-modal"; import { useAppContext } from "@/providers/app-context"; import { apiFetch } from "@/lib/api"; -import { hasAppAccess } from "@/lib/utils"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { toastSuccess, toastError } from "@/hooks/use-toast"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; @@ -34,11 +33,21 @@ import { PaginationEllipsis, } from "@/components/ui/pagination"; import type { FileGroup, FileGroupsResponse, TagInfo, TagsResponse } from "@/types/files"; +import { definePagePermissions, permission } from "@/lib/page-permissions"; +import { usePagePermissions } from "@/hooks/use-page-permissions"; const FILES_PER_PAGE = 15; +const PAGE_AUTHZ = definePagePermissions({ + read_files: permission("file_group", "read", "app"), + create_file: permission("file", "create", "app"), + update_file: permission("file", "update", "app"), +}); export default function FilesPage() { - const { token, org, app, getOrgAccess, getAppAccess } = useAppContext(); + const { token, org, app } = useAppContext(); + const permissions = usePagePermissions(PAGE_AUTHZ); + const canCreateFiles = permissions.can("create_file"); + const canUpdateFiles = permissions.can("update_file"); // Search and filter state const [searchQuery, setSearchQuery] = useState(""); @@ -261,7 +270,7 @@ export default function FilesPage() {

Files

Manage your application assets and resources

- {hasAppAccess(getOrgAccess(org), getAppAccess(org, app)) && ( + {canCreateFiles && ( - )} + {canCreateFiles && debouncedSearch === "" && selectedTag === "all" && ( + + )} ) : ( <> @@ -423,7 +430,7 @@ export default function FilesPage() { {formatDate(version.created_at)} - {hasAppAccess(getOrgAccess(org), getAppAccess(org, app)) && ( + {canUpdateFiles && ( + {release.experiment.status === "CREATED" && canUpdateRelease && ( + + )} - - - - - - - Discard Release - - Are you sure you want to discard this release? This action cannot be undone. - - - - - - - - - - )} + {release.experiment.status === "CREATED" && canDiscardRelease && ( + + + + + + + Discard Release + + Are you sure you want to discard this release? This action cannot be undone. + + + + + + + + + )} - + {canUpdateRelease && ( + + )} - {(release.experiment.status === "CREATED" || release.experiment.status === "INPROGRESS") && ( - - - + + + + Ramp Release + Enter new traffic percentage (0-50%): + + setTrafficPct(Number(e.target.value))} + className="mb-4" + /> + + + - - - - Ramp Release - Enter new traffic percentage (0-50%): - - setTrafficPct(Number(e.target.value))} - className="mb-4" - /> - - - - - - - )} + + + + )} - {release.experiment.status === "INPROGRESS" && ( - <> - - - - - - - Conclude Release - - Are you sure you want to conclude this release? This will roll out to 100% and finalize - the deployment. - - - - - - - - - - - - - - {/* Dialog content */} - - - Confirm Revert - - Are you sure you want to revert this release? This action cannot be undone. - - - - - - - - - - )} + {release.experiment.status === "INPROGRESS" && canConcludeRelease && ( + <> + + + + + + + Conclude Release + + Are you sure you want to conclude this release? This will roll out to 100% and finalize the + deployment. + + + + + + + + + + + + + + {/* Dialog content */} + + + Confirm Revert + + Are you sure you want to revert this release? This action cannot be undone. + + + + + + + + )} diff --git a/airborne_dashboard/app/dashboard/[orgId]/[appId]/releases/create/page.tsx b/airborne_dashboard/app/dashboard/[orgId]/[appId]/releases/create/page.tsx index 148c9c1a..a16ed78b 100644 --- a/airborne_dashboard/app/dashboard/[orgId]/[appId]/releases/create/page.tsx +++ b/airborne_dashboard/app/dashboard/[orgId]/[appId]/releases/create/page.tsx @@ -1,15 +1,18 @@ "use client"; -import { notFound, useParams } from "next/navigation"; -import { useAppContext } from "@/providers/app-context"; -import { hasAppAccess } from "@/lib/utils"; +import { notFound } from "next/navigation"; import { ReleaseBuilder } from "@/components/release"; +import { definePagePermissions, permission } from "@/lib/page-permissions"; +import { usePagePermissions } from "@/hooks/use-page-permissions"; + +const PAGE_AUTHZ = definePagePermissions({ + create_release: permission("release", "create", "app"), +}); export default function CreateReleasePage() { - const { org, getAppAccess, getOrgAccess, loadingAccess } = useAppContext(); - const params = useParams<{ appId: string }>(); + const permissions = usePagePermissions(PAGE_AUTHZ); - if (loadingAccess) { + if (!permissions.isReady) { return (

Checking access...

@@ -17,7 +20,7 @@ export default function CreateReleasePage() { ); } - const hasAccess = hasAppAccess(getOrgAccess(org), getAppAccess(org, params.appId), "write"); + const hasAccess = permissions.can("create_release"); if (!hasAccess) { notFound(); diff --git a/airborne_dashboard/app/dashboard/[orgId]/[appId]/releases/page.tsx b/airborne_dashboard/app/dashboard/[orgId]/[appId]/releases/page.tsx index 39625584..4c5c740c 100644 --- a/airborne_dashboard/app/dashboard/[orgId]/[appId]/releases/page.tsx +++ b/airborne_dashboard/app/dashboard/[orgId]/[appId]/releases/page.tsx @@ -10,7 +10,6 @@ import { Filter, Plus, Package } from "lucide-react"; import Link from "next/link"; import { apiFetch } from "@/lib/api"; import { useAppContext } from "@/providers/app-context"; -import { hasAppAccess } from "@/lib/utils"; import { useParams, useRouter } from "next/navigation"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { @@ -22,6 +21,13 @@ import { PaginationLink, PaginationEllipsis, } from "@/components/ui/pagination"; +import { definePagePermissions, permission } from "@/lib/page-permissions"; +import { usePagePermissions } from "@/hooks/use-page-permissions"; + +const PAGE_AUTHZ = definePagePermissions({ + read_releases: permission("release", "read", "app"), + create_release: permission("release", "create", "app"), +}); export type ApiRelease = { id: string; @@ -47,7 +53,8 @@ export default function ReleasesPage() { const [filterStatus, setFilterStatus] = useState(StatusFilter.ALL); const [page, setPage] = useState(1); const [count] = useState(20); // items per page - const { token, org, app, getAppAccess, getOrgAccess } = useAppContext(); + const { token, org, app } = useAppContext(); + const permissions = usePagePermissions(PAGE_AUTHZ); const params = useParams<{ appId: string }>(); const appId = typeof params.appId === "string" ? params.appId : Array.isArray(params.appId) ? params.appId[0] : ""; @@ -64,6 +71,7 @@ export default function ReleasesPage() { const releases: ApiRelease[] = data?.data || []; const totalPages = data?.total_pages || 1; + const canCreateRelease = permissions.can("create_release"); // helper to render page items const renderPaginationItems = (currentPage: number, totalPages: number, onPageChange: (page: number) => void) => { @@ -173,7 +181,7 @@ export default function ReleasesPage() {

Releases

Deploy packages to your users with controlled rollouts

- {hasAppAccess(getOrgAccess(org), getAppAccess(org, app)) && ( + {canCreateRelease && ( + {canCreateToken && ( + + )} {loading ? ( @@ -288,15 +313,17 @@ const TokensPage = () => {

Created: {formatDate(token.created_at)}

- + {canDeleteToken && ( + + )} @@ -308,80 +335,74 @@ const TokensPage = () => { - Confirm Your Password + {passwordLoginEnabled ? "Confirm Your Password" : "Continue with Single Sign-On"} - Please enter your account password to create a new personal access token. + {passwordLoginEnabled + ? "Please enter your account password to create a new personal access token." + : "Use your identity provider account to create a new personal access token."}
- {config?.google_signin_enabled && ( + {oidcProviders.length > 0 && ( <> - -
-
- -
-
- Or continue with + {oidcProviders.map((provider) => ( + + ))} + {passwordLoginEnabled && ( +
+
+ +
+
+ Or continue with +
-
+ )} )} -
- -
- setPassword(e.target.value)} - placeholder="Enter your password" - onKeyDown={(e) => e.key === "Enter" && confirmCreateToken()} - /> - + {passwordLoginEnabled && ( +
+ +
+ setPassword(e.target.value)} + placeholder="Enter your password" + onKeyDown={(e) => e.key === "Enter" && confirmCreateToken()} + /> + +
-
+ )}
- + {passwordLoginEnabled && ( + + )}
diff --git a/airborne_dashboard/app/dashboard/[orgId]/[appId]/users/page.tsx b/airborne_dashboard/app/dashboard/[orgId]/[appId]/users/page.tsx index 411b0b29..1148b3c8 100644 --- a/airborne_dashboard/app/dashboard/[orgId]/[appId]/users/page.tsx +++ b/airborne_dashboard/app/dashboard/[orgId]/[appId]/users/page.tsx @@ -2,16 +2,49 @@ import useSWR from "swr"; import { apiFetch } from "@/lib/api"; import { useAppContext } from "@/providers/app-context"; -import { UserManagement, type AccessLevel, type User } from "@/components/user-management"; +import { + UserManagement, + type AccessLevel, + type PermissionOption, + type RoleOption, + type User, +} from "@/components/user-management"; +import { definePagePermissions, permission } from "@/lib/page-permissions"; +import { usePagePermissions } from "@/hooks/use-page-permissions"; type OrgUsers = { users: User[] }; +type RoleListResponse = { roles: RoleOption[] }; +type PermissionListResponse = { permissions: PermissionOption[] }; + +const PAGE_AUTHZ = definePagePermissions({ + read_users: permission("application_user", "read", "app"), + create_user: permission("application_user", "create", "app"), + update_user: permission("application_user", "update", "app"), + delete_user: permission("application_user", "delete", "app"), + read_roles: permission("application_role", "read", "app"), + create_roles: permission("application_role", "create", "app"), +}); export default function ApplicationUsersPage() { - const { token, org, app, getAppAccess, getOrgAccess, updateOrgs } = useAppContext(); + const { token, org, app, updateOrgs } = useAppContext(); + const permissions = usePagePermissions(PAGE_AUTHZ); + const canReadRoles = permissions.can("read_roles"); + const canCreateRoles = permissions.can("create_roles"); const { data, error, mutate } = useSWR( token && org ? "/organisations/applications/user/list" : null, (url: string) => apiFetch(url, {}, { token, org, app }) ); + const { data: roleData, mutate: mutateRoles } = useSWR( + token && org && canReadRoles ? "/organisations/applications/user/roles/list" : null, + (url: string) => apiFetch(url, {}, { token, org, app }) + ); + const { data: permissionData } = useSWR( + token && org && canReadRoles ? "/organisations/applications/user/permissions/list" : null, + (url: string) => apiFetch(url, {}, { token, org, app }) + ); + const { data: orgUsersData } = useSWR(token && org ? "/organisations/user/list" : null, (url: string) => + apiFetch(url, {}, { token, org }) + ); const addUser = async (user: string, access: AccessLevel) => { await apiFetch( @@ -39,19 +72,38 @@ export default function ApplicationUsersPage() { updateOrgs(); }; + const upsertRole = async (role: string, permissions: string[]) => { + await apiFetch( + "/organisations/applications/user/roles/upsert", + { method: "POST", body: { role, permissions } }, + { token, org, app } + ); + mutateRoles(); + }; + if (error) { return
Error loading users
; } + if (permissions.isReady && !permissions.can("read_users")) { + return
You do not have permission to view application users.
; + } + return (
user.username) || []} title="Application Users" description="Manage users and their access levels for this application" entityType="application" diff --git a/airborne_dashboard/app/dashboard/[orgId]/[appId]/views/page.tsx b/airborne_dashboard/app/dashboard/[orgId]/[appId]/views/page.tsx index eae281cf..1091cbcd 100644 --- a/airborne_dashboard/app/dashboard/[orgId]/[appId]/views/page.tsx +++ b/airborne_dashboard/app/dashboard/[orgId]/[appId]/views/page.tsx @@ -9,7 +9,15 @@ import { useAppContext } from "@/providers/app-context"; import EditReleaseView from "@/components/releaseViews/EditReleaseView"; import DeleteReleaseView from "@/components/releaseViews/DeleteReleaseView"; import ViewReleaseInfo from "@/components/releaseViews/ViewReleaseInfo"; -import { hasAppAccess } from "@/lib/utils"; +import { definePagePermissions, permission } from "@/lib/page-permissions"; +import { usePagePermissions } from "@/hooks/use-page-permissions"; + +const PAGE_AUTHZ = definePagePermissions({ + read_views: permission("release_view", "read", "app"), + create_view: permission("release_view", "create", "app"), + update_view: permission("release_view", "update", "app"), + delete_view: permission("release_view", "delete", "app"), +}); export type View = { id: string; @@ -27,7 +35,9 @@ type ReleaseViewListResponse = { }; export default function ViewsPage() { - const { token, org, app, getAppAccess, getOrgAccess } = useAppContext(); + const { token, org, app } = useAppContext(); + const permissions = usePagePermissions(PAGE_AUTHZ); + const canManageViews = permissions.can("create_view") || permissions.can("update_view"); const [viewsList, setViewsList] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -134,9 +144,7 @@ export default function ViewsPage() {

Create and manage custom filtered views for your dashboard

- {hasAppAccess(getOrgAccess(org), getAppAccess(org, app)) && ( - - )} + {canManageViews && } @@ -167,20 +175,26 @@ export default function ViewsPage() { className="flex items-center gap-1" onClick={(e) => e.stopPropagation()} // prevent toggle when clicking actions > - { - setViewsList((prev) => prev.map((v) => (v.id === updatedView.id ? updatedView : v))); - }} - /> - { - setViewsList((prev) => prev.filter((v) => v.id !== viewId)); - setTotalItems((prev) => prev - 1); - if (selectedView?.id === viewId) setSelectedView(null); - }} - /> + {permissions.can("update_view") && ( + { + setViewsList((prev) => + prev.map((v) => (v.id === updatedView.id ? updatedView : v)) + ); + }} + /> + )} + {permissions.can("delete_view") && ( + { + setViewsList((prev) => prev.filter((v) => v.id !== viewId)); + setTotalItems((prev) => prev - 1); + if (selectedView?.id === viewId) setSelectedView(null); + }} + /> + )} diff --git a/airborne_dashboard/app/dashboard/[orgId]/page.tsx b/airborne_dashboard/app/dashboard/[orgId]/page.tsx index ca4858a1..cc5fccdb 100644 --- a/airborne_dashboard/app/dashboard/[orgId]/page.tsx +++ b/airborne_dashboard/app/dashboard/[orgId]/page.tsx @@ -21,9 +21,17 @@ import { apiFetch } from "@/lib/api"; import { useAppContext } from "@/providers/app-context"; import { OrganisationsList } from "../page"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; +import { definePagePermissions, permission } from "@/lib/page-permissions"; +import { usePagePermissions } from "@/hooks/use-page-permissions"; + +const PAGE_AUTHZ = definePagePermissions({ + create_application: permission("application", "create", "org"), +}); export default function ApplicationsPage() { - const { token, org, logout, getOrgAccess } = useAppContext(); + const { token, org, logout } = useAppContext(); + const permissions = usePagePermissions(PAGE_AUTHZ); + const canCreateApplication = permissions.can("create_application"); const [searchQuery, setSearchQuery] = useState(""); const debouncedSearchQuery = useDebouncedValue(searchQuery, 500); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); @@ -43,6 +51,7 @@ export default function ApplicationsPage() { }, [apps, debouncedSearchQuery]); const handleCreate = async () => { + if (!canCreateApplication) return; await apiFetch( "/organisations/applications/create", { method: "POST", body: { application: formData.name } }, @@ -62,7 +71,7 @@ export default function ApplicationsPage() {

Manage your organization, applications, and team members

- {getOrgAccess(org).includes("admin") && ( + {canCreateApplication && ( + + +
+ +
+ {clientSecret} + +
+
+ + + + + ); +} + +function ServiceAccountsSection({ + canCreate, + canDelete, + token, + org, +}: { + canCreate: boolean; + canDelete: boolean; + token: string | null; + org: string | null; +}) { + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isRotateDialogOpen, setIsRotateDialogOpen] = useState(false); + const [newCredentials, setNewCredentials] = useState(null); + const [rotatedCredentials, setRotatedCredentials] = useState(null); + const [accountToDelete, setAccountToDelete] = useState(null); + const [accountToRotate, setAccountToRotate] = useState(null); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [role, setRole] = useState("read"); + const [isCreating, setIsCreating] = useState(false); + + const { data, mutate } = useSWR( + token && org ? "/service-accounts" : null, + (url: string) => apiFetch(url, {}, { token, org }) + ); + + const handleCreate = async () => { + if (!name.trim()) return; + setIsCreating(true); + try { + const result = await apiFetch( + "/service-accounts", + { method: "POST", body: { name: name.trim(), description, role } }, + { token, org } + ); + setNewCredentials(result); + mutate(); + toastSuccess("Service Account Created", `Service account '${result.name}' created successfully`); + } catch { + // Error handled by apiFetch + } finally { + setIsCreating(false); + } + }; + + const handleDelete = async () => { + if (!accountToDelete) return; + try { + await apiFetch(`/service-accounts/${accountToDelete.client_id}`, { method: "DELETE" }, { token, org }); + mutate(); + setIsDeleteDialogOpen(false); + setAccountToDelete(null); + toastSuccess("Service Account Deleted", `Service account '${accountToDelete.name}' has been deleted`); + } catch { + // Error handled by apiFetch + } + }; + + const handleRotate = async () => { + if (!accountToRotate) return; + try { + const result = await apiFetch( + `/service-accounts/${accountToRotate.client_id}/rotate`, + { method: "POST" }, + { token, org } + ); + setRotatedCredentials(result); + toastSuccess("Credentials Rotated", `New credentials generated for '${accountToRotate.name}'`); + } catch { + // Error handled by apiFetch + } + }; + + const resetCreateDialog = () => { + setIsCreateDialogOpen(false); + setNewCredentials(null); + setName(""); + setDescription(""); + setRole("read"); + }; + + const resetRotateDialog = () => { + setIsRotateDialogOpen(false); + setRotatedCredentials(null); + setAccountToRotate(null); + }; + + const accounts = data?.data ?? []; + + return ( + + +
+
+ Service Accounts +

+ Manage service accounts for programmatic access to this organisation +

+
+ {canCreate && ( + { + if (!open) resetCreateDialog(); + else setIsCreateDialogOpen(true); + }} + > + + + + + + {newCredentials ? "Service Account Created" : "Create Service Account"} + + + {newCredentials ? ( +
+ +

+ Email: {newCredentials.email} +

+
+ +
+
+ ) : ( +
+
+ + setName(e.target.value)} + /> +

+ Lowercase letters, numbers, hyphens, and underscores only +

+
+
+ + setDescription(e.target.value)} + /> +
+
+ + +
+
+ + +
+
+ )} +
+
+ )} +
+
+ +
+ {accounts.length === 0 ? ( +
No service accounts found.
+ ) : ( + accounts.map((account) => ( +
+
+
+ +
+
+
+

{account.name}

+ + Service Account + +
+

{account.email}

+ {account.description && ( +

{account.description}

+ )} +
+
+ +
+ + Created {new Date(account.created_at).toLocaleDateString()} + + {canCreate && ( + + )} + {canDelete && ( + + )} +
+
+ )) + )} +
+
+ + {/* Delete confirmation dialog */} + + + + Delete Service Account + +
+

+ Are you sure you want to delete the service account {accountToDelete?.name}? This will + revoke all access immediately. This action cannot be undone. +

+
+ + +
+
+
+
+ + {/* Rotate credentials dialog */} + { + if (!open) resetRotateDialog(); + }} + > + + + {rotatedCredentials ? "New Credentials" : "Rotate Credentials"} + + {rotatedCredentials ? ( +
+ +
+ +
+
+ ) : ( +
+

+ This will generate new credentials for {accountToRotate?.name}. The old credentials + will stop working immediately. +

+
+ + +
+
+ )} +
+
+
+ ); +} export default function OrganisationUsersPage() { - const { token, org, getOrgAccess, updateOrgs } = useAppContext(); + const { token, org, updateOrgs } = useAppContext(); + const permissions = usePagePermissions(PAGE_AUTHZ); + const canReadRoles = permissions.can("read_roles"); + const canCreateRoles = permissions.can("create_roles"); + const canManageServiceAccounts = permissions.can("create_service_account") || permissions.can("read_service_account"); + const { data, error, mutate } = useSWR(token && org ? "/organisations/user/list" : null, (url: string) => apiFetch(url, {}, { token, org }) ); + const { data: roleData, mutate: mutateRoles } = useSWR( + token && org && canReadRoles ? "/organisations/user/roles/list" : null, + (url: string) => apiFetch(url, {}, { token, org }) + ); + const { data: permissionData } = useSWR( + token && org && canReadRoles ? "/organisations/user/permissions/list" : null, + (url: string) => apiFetch(url, {}, { token, org }) + ); + + // Filter out service account users from the regular users list + const regularUsers = (data?.users || []).filter( + (user) => !user.username.endsWith("@service-account.airborne.juspay.in") + ); const addUser = async (user: string, access: AccessLevel) => { await apiFetch("/organisations/user/create", { method: "POST", body: { user, access } }, { token, org }); @@ -36,23 +525,48 @@ export default function OrganisationUsersPage() { updateOrgs(); }; + const upsertRole = async (role: string, permissions: string[]) => { + await apiFetch("/organisations/user/roles/upsert", { method: "POST", body: { role, permissions } }, { token, org }); + mutateRoles(); + }; + if (error) { return
Error loading users
; } + if (permissions.isReady && !permissions.can("read_users")) { + return
You do not have permission to view organisation users.
; + } + return ( -
+
+ + {canManageServiceAccounts && ( + + )}
); } diff --git a/airborne_dashboard/app/dashboard/onboarding/page.tsx b/airborne_dashboard/app/dashboard/onboarding/page.tsx index 72ecd558..5f4e5328 100644 --- a/airborne_dashboard/app/dashboard/onboarding/page.tsx +++ b/airborne_dashboard/app/dashboard/onboarding/page.tsx @@ -9,8 +9,13 @@ import { Textarea } from "@/components/ui/textarea"; import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Rocket, Check, ChevronRight, Smartphone, Globe, Users, Target, Zap, Shield, BarChart3 } from "lucide-react"; +import { definePagePermissions } from "@/lib/page-permissions"; +import { usePagePermissions } from "@/hooks/use-page-permissions"; + +const PAGE_AUTHZ = definePagePermissions({}); export default function OnboardingPage() { + usePagePermissions(PAGE_AUTHZ); const [currentStep, setCurrentStep] = useState(1); const totalSteps = 3; diff --git a/airborne_dashboard/app/dashboard/page.tsx b/airborne_dashboard/app/dashboard/page.tsx index a436a0f4..d811996e 100644 --- a/airborne_dashboard/app/dashboard/page.tsx +++ b/airborne_dashboard/app/dashboard/page.tsx @@ -10,14 +10,22 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Card, CardContent, CardFooter, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { definePagePermissions, permission } from "@/lib/page-permissions"; +import { usePagePermissions } from "@/hooks/use-page-permissions"; export interface OrganisationsList { organisations: { name: string; applications: { application: string; organisation: string }[] }[]; } +const PAGE_AUTHZ = definePagePermissions({ + create_application: permission("application", "create", "org"), +}); + export default function DashboardHome() { const router = useRouter(); const { org, app, setOrg, setApp, token, logout, config, user } = useAppContext(); + const permissions = usePagePermissions(PAGE_AUTHZ); + const canCreateApplication = permissions.can("create_application"); const [reqOrgName, setReqOrgName] = useState(""); const [name, setName] = useState(""); const [email, setEmail] = useState(""); @@ -90,6 +98,7 @@ export default function DashboardHome() { }; const onCreateApp = async () => { + if (!canCreateApplication) return; await apiFetch( "/organisations/applications/create", { method: "POST", body: { application: appName } }, @@ -250,7 +259,7 @@ export default function DashboardHome() {

Applications group files, packages, and releases.

setAppName(e.target.value)} className="mb-3" /> -
diff --git a/airborne_dashboard/app/login/page.tsx b/airborne_dashboard/app/login/page.tsx index dd183126..cbebe319 100644 --- a/airborne_dashboard/app/login/page.tsx +++ b/airborne_dashboard/app/login/page.tsx @@ -12,6 +12,7 @@ import { Mail, Lock, Eye, EyeOff } from "lucide-react"; import Link from "next/link"; import Image from "next/image"; import { apiFetch } from "@/lib/api"; +import { GENERIC_OIDC_PROVIDER, resolveOidcProviders } from "@/lib/oidc-providers"; import { useAppContext } from "@/providers/app-context"; import { useRouter } from "next/navigation"; @@ -23,6 +24,16 @@ export default function LoginPage() { const [isLoading, setIsLoading] = useState(false); const { setToken, setUser, setOrg: setOrganisation, setApp: setApplication, token, config } = useAppContext(); const router = useRouter(); + const configuredOidcProviders = resolveOidcProviders(config?.enabled_oidc_idps); + const oidcLoginEnabled = + config?.oidc_login_enabled ?? config?.google_signin_enabled ?? configuredOidcProviders.length > 0; + const oidcProviders = oidcLoginEnabled + ? configuredOidcProviders.length > 0 + ? configuredOidcProviders + : [GENERIC_OIDC_PROVIDER] + : []; + const passwordLoginEnabled = config?.password_login_enabled ?? true; + const registrationEnabled = config?.registration_enabled ?? false; useEffect(() => { if (token && token != "") { @@ -56,10 +67,13 @@ export default function LoginPage() { } }; - const handleGoogleLogin = async () => { + const handleOidcLogin = async (idp?: string) => { setIsLoading(true); try { - const data = await apiFetch<{ auth_url: string; state?: string }>("/users/oauth/url"); + const data = await apiFetch<{ auth_url: string; state?: string }>( + "/users/oauth/url", + idp ? { query: { idp } } : {} + ); if (data?.auth_url) { localStorage.setItem("oauthAction", "login"); window.location.href = data.auth_url; @@ -67,7 +81,7 @@ export default function LoginPage() { throw new Error("OAuth URL not available"); } } catch (e: any) { - console.log("Google Login Error", e); + console.log("OIDC Login Error", e); setIsLoading(false); // Error toast will be shown automatically by apiFetch } @@ -109,118 +123,112 @@ export default function LoginPage() { {/* Google Sign In */} - {config?.google_signin_enabled && ( + {oidcProviders.length > 0 && ( <> - -
-
- -
-
- Or continue with + {oidcProviders.map((provider) => ( + + ))} + {passwordLoginEnabled && ( +
+
+ +
+
+ Or continue with +
-
+ )} )} {/* Email/Password Form */} -
-
- -
- - setName(e.target.value)} - className="pl-10" - required - /> + {passwordLoginEnabled && ( + +
+ +
+ + setName(e.target.value)} + className="pl-10" + required + /> +
-
-
- -
- - setPassword(e.target.value)} - className="pl-10 pr-10" - required - /> - +
+ +
+ + setPassword(e.target.value)} + className="pl-10 pr-10" + required + /> + +
-
-
-
- setRememberMe(checked as boolean)} - /> - +
+
+ setRememberMe(checked as boolean)} + /> + +
+ {registrationEnabled && ( + + Create account + + )}
- - Create account - -
- - + + + )} -
- Don't have an account? - - Sign up - -
+ {registrationEnabled && ( +
+ Don't have an account? + + Sign up + +
+ )} diff --git a/airborne_dashboard/app/oauth/callback/page.tsx b/airborne_dashboard/app/oauth/callback/page.tsx index 7a1d9323..d682c113 100644 --- a/airborne_dashboard/app/oauth/callback/page.tsx +++ b/airborne_dashboard/app/oauth/callback/page.tsx @@ -31,13 +31,18 @@ type OAuthPatResponse = { export default function OAuthCallback() { const params = useSearchParams(); const router = useRouter(); - const { setToken, setUser, token, org, app, setOrg, setApp, loading } = useApp(); + const { setToken, setUser, token, org, app, setOrg, setApp, loading, config } = useApp(); const processedCode = useRef(false); useEffect(() => { const code = params.get("code"); const state = params.get("state") || undefined; - const oauthAction = localStorage.getItem("oauthAction") || "login"; + const requestedAction = localStorage.getItem("oauthAction") || "login"; + + // Wait for AppProvider bootstrap so we don't treat null config as final while still loading. + if (loading) return; + const registrationEnabled = config?.registration_enabled ?? true; + const oauthAction = requestedAction === "signup" && !registrationEnabled ? "login" : requestedAction; // Wait for org/app to be loaded only for generate_pat if (oauthAction === "generate_pat") { @@ -88,7 +93,7 @@ export default function OAuthCallback() { router.replace("/login"); } })(); - }, [loading, token, org, app]); // Include all dependencies to re-run when session loads + }, [loading, token, org, app, config, params, router, setToken, setUser, setOrg, setApp]); // Include all dependencies to re-run when session loads return
Completing sign-in...
; } diff --git a/airborne_dashboard/app/page.tsx b/airborne_dashboard/app/page.tsx index 50d91c28..7f4310ba 100644 --- a/airborne_dashboard/app/page.tsx +++ b/airborne_dashboard/app/page.tsx @@ -6,8 +6,11 @@ import { Badge } from "@/components/ui/badge"; import { Target, Zap, Shield, BarChart3, Users, Globe, ArrowRight, Star, ChevronRight } from "lucide-react"; import Link from "next/link"; import Image from "next/image"; +import { useAppContext } from "@/providers/app-context"; export default function LandingPage() { + const { config } = useAppContext(); + const registrationEnabled = config?.registration_enabled ?? false; const features = [ { icon: Target, @@ -111,9 +114,11 @@ export default function LandingPage() { - + {registrationEnabled && ( + + )}
@@ -136,12 +141,14 @@ export default function LandingPage() {

- + {registrationEnabled && ( + + )}
@@ -258,12 +265,14 @@ export default function LandingPage() { today.

- + {registrationEnabled && ( + + )} + {oidcProviders.map((provider) => ( + + ))}
diff --git a/airborne_dashboard/components/release/steps/PackageSelectionStep.tsx b/airborne_dashboard/components/release/steps/PackageSelectionStep.tsx index 8c2d1307..81fa5241 100644 --- a/airborne_dashboard/components/release/steps/PackageSelectionStep.tsx +++ b/airborne_dashboard/components/release/steps/PackageSelectionStep.tsx @@ -13,11 +13,17 @@ import { PaginationControls } from "../PaginationControls"; import { apiFetch } from "@/lib/api"; import { useAppContext } from "@/providers/app-context"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; -import { hasAppAccess } from "@/lib/utils"; import { Pkg } from "@/types/release"; +import { definePagePermissions, permission } from "@/lib/page-permissions"; +import { usePagePermissions } from "@/hooks/use-page-permissions"; + +const PAGE_AUTHZ = definePagePermissions({ + create_package: permission("package", "create", "app"), +}); export function PackageSelectionStep() { - const { token, org, app, getAppAccess, getOrgAccess } = useAppContext(); + const { token, org, app } = useAppContext(); + const permissions = usePagePermissions(PAGE_AUTHZ); const { mode, selectedPackage, setSelectedPackage } = useReleaseForm(); const [packages, setPackages] = useState([]); @@ -95,7 +101,7 @@ export function PackageSelectionStep() { ? `No packages found matching "${pkgSearch}".` : "You haven't created any packages yet."}

- {hasAppAccess(getOrgAccess(org), getAppAccess(org, app)) && pkgSearch.trim() === "" && ( + {permissions.can("create_package") && pkgSearch.trim() === "" && ( - setCreateFileOpen(true)}> + setCreateFileOpen(true)}> Create File - + @@ -304,7 +318,7 @@ export default function SharedLayout({ children }: SharedLayoutProps) { Create Package - + diff --git a/airborne_dashboard/components/user-management.tsx b/airborne_dashboard/components/user-management.tsx index ee9229e3..71d6a2f2 100644 --- a/airborne_dashboard/components/user-management.tsx +++ b/airborne_dashboard/components/user-management.tsx @@ -1,146 +1,231 @@ "use client"; -import React, { useMemo } from "react"; + +import React, { useEffect, useMemo, useState } from "react"; +import { ArrowRight, Crown, MoreVertical, Search, Trash2, UserPlus } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Search, UserPlus, MoreVertical, Trash2, ArrowRight, Crown } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; -export type AccessLevel = "owner" | "admin" | "write" | "read"; +export type AccessLevel = string; export interface User { username: string; - roles: AccessLevel[]; + roles: string[]; +} + +export interface PermissionOption { + key: string; + resource: string; + action: string; +} + +export interface RoleOption { + role: string; + is_system?: boolean; + permissions?: PermissionOption[]; } export interface UserManagementProps { users: User[]; - currentUserOrgAccess: string[]; - currentUserAppAccess?: string[]; + canAddUser?: boolean; + canUpdateUser?: boolean; + canRemoveUser?: boolean; + canTransferOwnership?: boolean; + canManageRoles?: boolean; onAddUser: (user: string, access: AccessLevel) => Promise; onUpdateUser: (user: string, access: AccessLevel) => Promise; onRemoveUser: (user: string) => Promise; onTransferOwnership?: (user: string) => Promise; + onCreateRole?: (role: string, permissions: string[]) => Promise; + roles?: RoleOption[]; + availablePermissions?: PermissionOption[]; + availableUsers?: string[]; title?: string; description?: string; entityType?: "organisation" | "application"; } -const ACCESS_LEVELS: { value: AccessLevel; label: string; color: string }[] = [ - { value: "owner", label: "Owner", color: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200" }, - { value: "admin", label: "Admin", color: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200" }, - { value: "write", label: "Write", color: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200" }, - { value: "read", label: "Read", color: "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200" }, -]; +const SYSTEM_ROLE_ORDER = ["owner", "admin", "write", "read"]; + +const getRoleRank = (role: string): number => { + const idx = SYSTEM_ROLE_ORDER.indexOf(role); + return idx === -1 ? 99 : idx; +}; -const getAccessLevelRoles = (level: AccessLevel): AccessLevel[] => { - switch (level) { +const getRoleMeta = (role: string) => { + switch (role) { case "owner": - return ["owner", "admin", "write", "read"]; + return { label: "Owner", color: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200" }; case "admin": - return ["admin", "write", "read"]; + return { label: "Admin", color: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200" }; case "write": - return ["write", "read"]; + return { label: "Write", color: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200" }; case "read": - return ["read"]; + return { label: "Read", color: "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200" }; default: - return ["read"]; + return { + label: role + .split(/[_-]/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "), + color: "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200", + }; } }; -const canUpdateUsers = ( - entityType: "organisation" | "application", - orgAccess: string[], - appAccess?: string[] -): boolean => { - if (entityType === "organisation") { - return orgAccess.includes("admin"); - } - if (!appAccess) return false; - return orgAccess.includes("admin") || (orgAccess.includes("read") && appAccess.includes("admin")); +const getPrimaryRole = (roles: string[]): string | null => { + if (!roles.length) return null; + const sorted = [...roles].sort((left, right) => { + const rankDiff = getRoleRank(left) - getRoleRank(right); + if (rankDiff !== 0) return rankDiff; + return left.localeCompare(right); + }); + return sorted[0] ?? null; }; -const getHighestAccessLevel = (roles: AccessLevel[]): AccessLevel => { - const accessHierarchy: AccessLevel[] = ["owner", "admin", "write", "read"]; - for (const level of accessHierarchy) { - if (roles.includes(level)) { - return level; - } - } - return "read"; + +const defaultRoleFrom = (roleValues: string[]): string => { + if (roleValues.includes("read")) return "read"; + return roleValues[0] ?? "read"; }; +const isValidRoleKey = (value: string): boolean => /^[a-z_]+$/.test(value); + export function UserManagement({ users, - currentUserOrgAccess, - currentUserAppAccess, + canAddUser = false, + canUpdateUser = false, + canRemoveUser = false, + canTransferOwnership = false, + canManageRoles = false, onAddUser, onUpdateUser, onRemoveUser, onTransferOwnership, + onCreateRole, + roles = [], + availablePermissions = [], + availableUsers = [], title = "User Management", description = "Manage users and their access levels", entityType = "organisation", }: UserManagementProps) { - const [search, setSearch] = React.useState(""); + const [search, setSearch] = useState(""); const debouncedSearch = useDebouncedValue(search, 500); - const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false); - const [newUser, setNewUser] = React.useState(""); - const [newUserAccess, setNewUserAccess] = React.useState("read"); - const [isConfirmDialogOpen, setIsConfirmDialogOpen] = React.useState(false); - const [userToRemove, setUserToRemove] = React.useState(null); - const [isTransferDialogOpen, setIsTransferDialogOpen] = React.useState(false); - const [userToTransfer, setUserToTransfer] = React.useState(null); - - const canUpdate = canUpdateUsers(entityType, currentUserOrgAccess, currentUserAppAccess); + const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); + const [isUserPickerOpen, setIsUserPickerOpen] = useState(false); + const [newUser, setNewUser] = useState(""); + const [newUserAccess, setNewUserAccess] = useState("read"); + const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); + const [userToRemove, setUserToRemove] = useState(null); + const [isTransferDialogOpen, setIsTransferDialogOpen] = useState(false); + const [userToTransfer, setUserToTransfer] = useState(null); + const [roleName, setRoleName] = useState(""); + const [editingRoleKey, setEditingRoleKey] = useState(null); + const [permissionSearch, setPermissionSearch] = useState(""); + const [selectedPermissions, setSelectedPermissions] = useState>(new Set()); + + const canManageAnyUserAction = + canUpdateUser || canRemoveUser || (entityType === "organisation" && canTransferOwnership); + const canEditRoles = Boolean(onCreateRole && canManageRoles); + const hasRoleTab = roles.length > 0 || canEditRoles; + const hasOrgUserPicker = entityType === "application" && availableUsers.length > 0; + const filteredUsers = useMemo(() => { - return users.filter((u) => u.username.toLowerCase().includes(search.toLowerCase())); + return users.filter((user) => user.username.toLowerCase().includes(debouncedSearch.toLowerCase())); }, [users, debouncedSearch]); - const handleAddUser = async () => { - if (!newUser.trim()) return; - - try { - await onAddUser(newUser.trim(), newUserAccess); - setNewUser(""); - setNewUserAccess("read"); - setIsAddDialogOpen(false); - } catch (error) { - console.error("Failed to add user:", error); + const roleValues = useMemo(() => { + const dynamic = roles.map((entry) => entry.role).filter((role) => role && role !== "owner"); + if (dynamic.length > 0) { + return dynamic.sort((left, right) => { + const rankDiff = getRoleRank(left) - getRoleRank(right); + if (rankDiff !== 0) return rankDiff; + return left.localeCompare(right); + }); } - }; + return ["admin", "write", "read"]; + }, [roles]); - const handleUpdateAccess = async (user: string, newAccess: AccessLevel) => { - try { - await onUpdateUser(user, newAccess); - } catch (error) { - console.error("Failed to update user access:", error); + useEffect(() => { + if (!roleValues.includes(newUserAccess)) { + setNewUserAccess(defaultRoleFrom(roleValues)); } + }, [newUserAccess, roleValues]); + + const filteredRolePermissions = useMemo(() => { + if (!permissionSearch.trim()) return availablePermissions; + const query = permissionSearch.toLowerCase(); + return availablePermissions.filter( + (permission) => + permission.key.toLowerCase().includes(query) || + permission.resource.toLowerCase().includes(query) || + permission.action.toLowerCase().includes(query) + ); + }, [availablePermissions, permissionSearch]); + + const normalizedRoleName = roleName.trim().toLowerCase(); + const roleKeyError = + normalizedRoleName.length > 0 && !isValidRoleKey(normalizedRoleName) + ? "Role key can only contain lowercase letters (a-z) and underscore (_)." + : null; + + const handleAddUser = async () => { + const targetUser = hasOrgUserPicker ? newUser : newUser.trim(); + if (!targetUser) return; + + await onAddUser(targetUser, newUserAccess); + setNewUser(""); + setNewUserAccess(defaultRoleFrom(roleValues)); + setIsAddDialogOpen(false); + }; + + const handleUpdateAccess = async (user: string, access: AccessLevel) => { + await onUpdateUser(user, access); + }; + + const handleRemoveUser = (user: string) => { + setUserToRemove(user); + setIsConfirmDialogOpen(true); + }; + + const confirmRemoveUser = async () => { + if (!userToRemove) return; + await onRemoveUser(userToRemove); + setIsConfirmDialogOpen(false); + setUserToRemove(null); }; - const handleTransferOwnership = async (user: string) => { + const cancelRemoveUser = () => { + setIsConfirmDialogOpen(false); + setUserToRemove(null); + }; + + const handleTransferOwnership = (user: string) => { setUserToTransfer(user); setIsTransferDialogOpen(true); }; const confirmTransferOwnership = async () => { if (!userToTransfer || !onTransferOwnership) return; - - try { - await onTransferOwnership(userToTransfer); - setIsTransferDialogOpen(false); - setUserToTransfer(null); - } catch (error) { - console.error("Failed to transfer ownership:", error); - } + await onTransferOwnership(userToTransfer); + setIsTransferDialogOpen(false); + setUserToTransfer(null); }; const cancelTransferOwnership = () => { @@ -148,247 +233,385 @@ export function UserManagement({ setUserToTransfer(null); }; - const handleRemoveUser = async (user: string) => { - setUserToRemove(user); - setIsConfirmDialogOpen(true); + const togglePermission = (key: string) => { + setSelectedPermissions((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); }; - const confirmRemoveUser = async () => { - if (!userToRemove) return; - - try { - await onRemoveUser(userToRemove); - setIsConfirmDialogOpen(false); - setUserToRemove(null); - } catch (error) { - console.error("Failed to remove user:", error); - } + const handleCreateRole = async () => { + if (!onCreateRole) return; + if (!normalizedRoleName || roleKeyError || selectedPermissions.size === 0) return; + await onCreateRole(normalizedRoleName, Array.from(selectedPermissions)); + setEditingRoleKey(null); + setRoleName(""); + setPermissionSearch(""); + setSelectedPermissions(new Set()); }; - const allowedAccessLevels: AccessLevel[] = ["admin", "write", "read"]; + const beginEditRole = (role: RoleOption) => { + const existingPermissions = role.permissions?.map((permission) => permission.key) ?? []; + setEditingRoleKey(role.role); + setRoleName(role.role); + setPermissionSearch(""); + setSelectedPermissions(new Set(existingPermissions)); + }; - const cancelRemoveUser = () => { - setIsConfirmDialogOpen(false); - setUserToRemove(null); + const resetRoleEditor = () => { + setEditingRoleKey(null); + setRoleName(""); + setPermissionSearch(""); + setSelectedPermissions(new Set()); }; - return ( -
- - -
-
- {title} -

{description}

-
- {canUpdate && ( - - - - - - - Add New User - -
-
- + const usersContent = ( + + +
+
+ {title} +

{description}

+
+ {canAddUser && ( + + + + + + + Add New User + +
+
+ + {hasOrgUserPicker ? ( + + + + + + + + + No users found. + + {availableUsers.map((user) => ( + { + setNewUser(selected); + setIsUserPickerOpen(false); + }} + > + {user} + + ))} + + + + + + ) : ( setNewUser(e.target.value)} + onChange={(event) => setNewUser(event.target.value)} /> -
-
- - -
-
- - -
+ )}
-
-
- )} +
+ + +
+
+ + +
+
+ +
+ )} +
+
+ +
+
+ + setSearch(event.target.value)} + />
- - -
-
- - setSearch(e.target.value)} - className="pl-10" - /> -
-
- {filteredUsers.length === 0 ? ( -
- {search ? "No users found matching your search." : "No users found."} -
- ) : ( - filteredUsers.map((user) => { - const userHighestLevel = getHighestAccessLevel(user.roles); - const availableAccessLevels = allowedAccessLevels.filter((level) => level !== userHighestLevel); - - return ( -
-
-
- - {user.username.charAt(0).toUpperCase()} - -
-
-

{user.username}

-
- {user.roles.map((role) => { - const config = ACCESS_LEVELS.find((l) => l.value === role)!; - return ( - - {config.label} - - ); - })} -
+
+ {filteredUsers.length === 0 ? ( +
+ {search ? "No users found matching your search." : "No users found."} +
+ ) : ( + filteredUsers.map((user) => { + const primaryRole = getPrimaryRole(user.roles); + const availableRoleUpdates = roleValues.filter((role) => role !== primaryRole); + + return ( +
+
+
+ + {user.username.charAt(0).toUpperCase()} + +
+
+

{user.username}

+
+ {user.roles.map((role) => { + const meta = getRoleMeta(role); + return ( + + {meta.label} + + ); + })}
+
- {canUpdate && !user.roles.includes("owner") && ( - - - - - - {entityType === "organisation" && currentUserOrgAccess.includes("owner") ? ( - handleTransferOwnership(user.username)} - className="flex-col cursor-pointer items-start p-3 hover:!bg-gray-100 dark:hover:!bg-[#1c1c21] border-b border-gray-200 dark:border-gray-700" - > -
-
- - - -
-
- - Transfer Ownership - -

- Make {user.username} the new owner of this organisation -

-
+ {canManageAnyUserAction && !user.roles.includes("owner") && ( + + + + + + {entityType === "organisation" && canTransferOwnership && ( + handleTransferOwnership(user.username)} + className="flex-col items-start cursor-pointer border-b border-gray-200 p-3 hover:!bg-gray-100 dark:border-gray-700 dark:hover:!bg-[#1c1c21]" + > +
+
+ +
+
+ + Transfer Ownership + +

+ Make {user.username} the new owner of this organisation +

- - ) : null} - {availableAccessLevels.map((level) => { - const config = ACCESS_LEVELS.find((l) => l.value === level)!; - const newRoles = getAccessLevelRoles(level); +
+
+ )} + {canUpdateUser && + availableRoleUpdates.map((role) => { + const meta = getRoleMeta(role); return ( handleUpdateAccess(user.username, level)} - className="flex-col cursor-pointer items-start p-3 hover:!bg-gray-100 dark:hover:!bg-[#1c1c21]" + key={role} + onClick={() => handleUpdateAccess(user.username, role)} + className="flex-col items-start cursor-pointer p-3 hover:!bg-gray-100 dark:hover:!bg-[#1c1c21]" > -
- - {config.label} +
+ + {meta.label} - Set access to {config.label} + Set role to {meta.label}
-
-
- {user.roles.map((role) => { - const config = ACCESS_LEVELS.find((l) => l.value === role)!; - return ( - - {config.label} - - ); - })} -
+
+ + {primaryRole ? getRoleMeta(primaryRole).label : "Current"} + -
- {newRoles.map((role) => { - const config = ACCESS_LEVELS.find((l) => l.value === role)!; - return ( - - {config.label} - - ); - })} -
+ + {meta.label} +
); })} + {canRemoveUser && ( handleRemoveUser(user.username)} - className="text-destructive focus:text-destructive hover:!bg-gray-100 dark:hover:!bg-[#1c1c21] cursor-pointer" + className="cursor-pointer text-destructive focus:text-destructive hover:!bg-gray-100 dark:hover:!bg-[#1c1c21]" > - + Remove User - - - )} -
- ); - }) - )} + )} + + + )} +
+ ); + }) + )} +
+
+ + + ); + + const rolesContent = ( + + + Roles +

+ Create custom roles by selecting resource-action permissions. System roles are read-only. +

+
+ + {canEditRoles ? ( +
+
+ + setRoleName(event.target.value.toLowerCase())} + disabled={Boolean(editingRoleKey)} + /> + {editingRoleKey ? ( +

+ Editing role {editingRoleKey}. Clear to create a new role key. +

+ ) : null} + {roleKeyError ?

{roleKeyError}

: null} +
+
+ + setPermissionSearch(event.target.value)} + /> +
+ {filteredRolePermissions.length === 0 ? ( +
No permissions found.
+ ) : ( + filteredRolePermissions.map((permission) => ( + + )) + )} +
+
+
+ +
-
-
+ ) : null} + +
+ {roles.length === 0 ? ( +
No roles available.
+ ) : ( + roles.map((role) => { + const meta = getRoleMeta(role.role); + return ( +
+
+
+ + {meta.label} + + {role.role} +
+ + {role.is_system ? "system" : "custom"} • {role.permissions?.length ?? 0} permissions + +
+ {!role.is_system && canEditRoles ? ( +
+ +
+ ) : null} +
+ ); + }) + )} +
+ + + ); + + return ( +
+ {hasRoleTab ? ( + + + Users + Roles + + {usersContent} + {rolesContent} + + ) : ( + usersContent + )} @@ -398,7 +621,7 @@ export function UserManagement({

Are you sure you want to remove {userToRemove} from this {entityType}? This action cannot - be undone and the user will lose all access immediately. + be undone.

+ @@ -421,63 +645,11 @@ export function UserManagement({
-
-
- - - -
-

- Warning: This action is irreversible -

-

- This will permanently transfer ownership to another user. -

-
-
-
- -
-

- You are about to transfer ownership of this {entityType} to{" "} - {userToTransfer}. -

- -
-

What will happen:

-
    -
  • - - - {userToTransfer} will become the new owner - -
  • -
  • - - You will lose all owner privileges -
  • -
  • - - Your access level will be changed to admin -
  • -
  • - - This action cannot be undone -
  • -
-
-
-
+

+ You are about to transfer ownership of this {entityType} to{" "} + {userToTransfer}. +

+
@@ -489,62 +661,6 @@ export function UserManagement({
- - - - Access Level Hierarchy - - -
-
-
-
- - Owner - - Can manage all users -
-
Includes: Admin, Write, Read access
-
-
-
- - Admin - - Can manage non-owner users -
-
Includes: Admin, Write, Read access
-
-
-
-
-
- - Write - - Cannot manage other users -
-
Includes: Write, Read access
-
-
-
- - Read - - Cannot manage other users -
-
Includes: Read access only
-
-
-
-
-
); } diff --git a/airborne_dashboard/hooks/use-page-permissions.ts b/airborne_dashboard/hooks/use-page-permissions.ts new file mode 100644 index 00000000..6ac792bb --- /dev/null +++ b/airborne_dashboard/hooks/use-page-permissions.ts @@ -0,0 +1,70 @@ +"use client"; + +import { useMemo } from "react"; +import useSWR from "swr"; + +import { enforcePermissionsBatch, type PermissionCheckRequest } from "@/lib/authz"; +import type { PagePermissionMap } from "@/lib/page-permissions"; +import { useAppContext } from "@/providers/app-context"; + +type PermissionDecisionMap = Record; + +type UsePagePermissionsResult = { + can: (key: K) => boolean; + checks: PermissionDecisionMap; + isLoading: boolean; + isReady: boolean; + error?: Error; + refresh: () => Promise; +}; + +export function usePagePermissions(permissionMap: PagePermissionMap): UsePagePermissionsResult { + const { token, org, app } = useAppContext(); + + const aliases = useMemo(() => Object.keys(permissionMap) as K[], [permissionMap]); + const checks = useMemo( + () => + aliases.map((alias) => { + const value = permissionMap[alias]; + return { resource: value.resource, action: value.action, scope: value.scope }; + }), + [aliases, permissionMap] + ); + const requestFingerprint = useMemo( + () => checks.map((check) => `${check.scope ?? "auto"}:${check.resource}.${check.action}`).join("|"), + [checks] + ); + + const shouldFetch = Boolean(token && org && checks.length > 0); + const swrKey = shouldFetch ? ["page-permissions", token, org, app ?? "", requestFingerprint] : null; + + const { data, isLoading, error, mutate } = useSWR(swrKey, () => + enforcePermissionsBatch({ token, org, app }, checks as PermissionCheckRequest[]) + ); + + const decisions = useMemo(() => { + const resolved = {} as PermissionDecisionMap; + aliases.forEach((alias, index) => { + resolved[alias] = data?.results?.[index]?.allowed ?? false; + }); + return resolved; + }, [aliases, data]); + + const can = (key: K) => { + if (aliases.length === 0) return true; + return decisions[key] ?? false; + }; + + const refresh = async () => { + await mutate(); + }; + + return { + can, + checks: decisions, + isLoading: shouldFetch ? isLoading : false, + isReady: aliases.length === 0 || Boolean(data) || Boolean(error), + error: error as Error | undefined, + refresh, + }; +} diff --git a/airborne_dashboard/lib/authz.ts b/airborne_dashboard/lib/authz.ts new file mode 100644 index 00000000..b9732e63 --- /dev/null +++ b/airborne_dashboard/lib/authz.ts @@ -0,0 +1,58 @@ +import { apiFetch } from "@/lib/api"; + +export type AuthzScope = "org" | "app" | "auto"; + +export interface PermissionCatalogItem { + key: string; + resource: string; + action: string; + scope: AuthzScope; +} + +export interface PermissionCatalogResponse { + permissions: PermissionCatalogItem[]; +} + +export interface PermissionCheckRequest { + resource: string; + action: string; + scope?: AuthzScope; +} + +export interface PermissionCheckResult { + key: string; + resource: string; + action: string; + scope: AuthzScope; + allowed: boolean; +} + +export interface EnforceBatchResponse { + results: PermissionCheckResult[]; +} + +export async function fetchPermissionCatalog( + ctx: { token?: string | null; org?: string | null; app?: string | null }, + scope?: AuthzScope +): Promise { + return apiFetch("/authz/catalog", { query: { scope } }, ctx); +} + +export async function enforcePermissionsBatch( + ctx: { token?: string | null; org?: string | null; app?: string | null }, + checks: PermissionCheckRequest[] +): Promise { + return apiFetch( + "/authz/me/enforce-batch", + { method: "POST", body: { checks }, showErrorToast: false }, + ctx + ); +} + +export function toPermissionDecisionMap(results: PermissionCheckResult[]): Record { + const output: Record = {}; + for (const entry of results) { + output[`${entry.scope}:${entry.key}`] = entry.allowed; + } + return output; +} diff --git a/airborne_dashboard/lib/oidc-providers.tsx b/airborne_dashboard/lib/oidc-providers.tsx new file mode 100644 index 00000000..b88b9881 --- /dev/null +++ b/airborne_dashboard/lib/oidc-providers.tsx @@ -0,0 +1,80 @@ +import type { ComponentType } from "react"; +import { Github, Globe, Shield } from "lucide-react"; + +export type OidcProviderUi = { + id: string; + label: string; + Icon: ComponentType<{ className?: string }>; +}; + +export const GENERIC_OIDC_PROVIDER: OidcProviderUi = { + id: "", + label: "Single Sign-On", + Icon: Shield, +}; + +const GoogleIcon = ({ className }: { className?: string }) => ( + +); + +const KNOWN_OIDC_PROVIDERS: Record = { + google: { id: "google", label: "Google", Icon: GoogleIcon }, + github: { id: "github", label: "GitHub", Icon: Github }, + auth0: { id: "auth0", label: "Auth0", Icon: Shield }, + okta: { id: "okta", label: "Okta", Icon: Shield }, +}; + +function humanizeProviderId(id: string): string { + return id + .split(/[-_\s]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +export function resolveOidcProviders(enabledIdps?: string[]): OidcProviderUi[] { + if (!enabledIdps || enabledIdps.length === 0) { + return []; + } + + const seen = new Set(); + return enabledIdps + .map((rawId) => rawId.trim().toLowerCase()) + .filter((id) => id.length > 0) + .filter((id) => { + if (seen.has(id)) { + return false; + } + seen.add(id); + return true; + }) + .map((id) => { + const known = KNOWN_OIDC_PROVIDERS[id]; + if (known) { + return known; + } + + return { + id, + label: humanizeProviderId(id), + Icon: Globe, + }; + }); +} diff --git a/airborne_dashboard/lib/page-permissions.ts b/airborne_dashboard/lib/page-permissions.ts new file mode 100644 index 00000000..a28f4fe7 --- /dev/null +++ b/airborne_dashboard/lib/page-permissions.ts @@ -0,0 +1,15 @@ +import type { AuthzScope, PermissionCheckRequest } from "@/lib/authz"; + +export type PagePermissionDefinition = PermissionCheckRequest & { + label?: string; +}; + +export type PagePermissionMap = Record; + +export function definePagePermissions(permissions: PagePermissionMap): PagePermissionMap { + return permissions; +} + +export function permission(resource: string, action: string, scope: AuthzScope = "auto"): PagePermissionDefinition { + return { resource, action, scope }; +} diff --git a/airborne_dashboard/next.config.mjs b/airborne_dashboard/next.config.mjs index fbde3a5a..4f31008b 100644 --- a/airborne_dashboard/next.config.mjs +++ b/airborne_dashboard/next.config.mjs @@ -15,7 +15,8 @@ const nextConfig = { destination: `https://airborne.juspay.in/analytics/:path*`, }, { - source: "/api/:api(releases|file|organisations|applications|users|packages|dashboard|token)/:path*", + source: + "/api/:api(releases|file|organisations|applications|users|packages|dashboard|token|authz|service-accounts)/:path*", destination: `${backend}/api/:api/:path*`, }, { diff --git a/airborne_dashboard/providers/app-context.tsx b/airborne_dashboard/providers/app-context.tsx index 47991280..afe4cc6a 100644 --- a/airborne_dashboard/providers/app-context.tsx +++ b/airborne_dashboard/providers/app-context.tsx @@ -37,7 +37,12 @@ type AppContextType = { interface Configuration { google_signin_enabled: boolean; + enabled_oidc_idps?: string[]; organisation_creation_disabled: boolean; + authn_provider?: "keycloak" | "oidc" | "okta" | "auth0"; + oidc_login_enabled?: boolean; + password_login_enabled?: boolean; + registration_enabled?: boolean; } const AppContext = createContext(undefined); @@ -54,9 +59,14 @@ export function AppProvider({ children }: { children: React.ReactNode }) { const [user, setUserState] = useState(null); const [loading, setLoading] = useState(true); const [config, setConfig] = useState(null); - const fetchConfig = async () => { - const res: Configuration = await apiFetch("/dashboard/configuration"); - setConfig(res); + const fetchConfig = async (): Promise => { + try { + const res: Configuration = await apiFetch("/dashboard/configuration"); + return res; + } catch (error) { + console.error("Failed to fetch dashboard configuration:", error); + return null; + } }; const fetchOrganisations = async () => { @@ -69,13 +79,23 @@ export function AppProvider({ children }: { children: React.ReactNode }) { const organisations = data?.organisations || []; useEffect(() => { - setTokenState(localStorage.getItem(LS_TOKEN)); - setOrgState(localStorage.getItem(LS_ORG)); - setAppState(localStorage.getItem(LS_APP)); - const u = localStorage.getItem(LS_USER); - if (u) setUserState(JSON.parse(u)); - fetchConfig(); - setLoading(false); + let isMounted = true; + const initialize = async () => { + setTokenState(localStorage.getItem(LS_TOKEN)); + setOrgState(localStorage.getItem(LS_ORG)); + setAppState(localStorage.getItem(LS_APP)); + const u = localStorage.getItem(LS_USER); + if (u) setUserState(JSON.parse(u)); + const resolvedConfig = await fetchConfig(); + if (!isMounted) return; + setConfig(resolvedConfig); + setLoading(false); + }; + void initialize(); + + return () => { + isMounted = false; + }; }, []); const setToken = (t: string | null) => { diff --git a/airborne_server/.env.example b/airborne_server/.env.example index 2d0c1597..d3ba7a21 100644 --- a/airborne_server/.env.example +++ b/airborne_server/.env.example @@ -2,13 +2,35 @@ SUPERPOSITION_URL=http://localhost:8080 SUPERPOSITION_ORG_ID=get-org-id-from-superposition -# Keycloak settings -KEYCLOAK_URL=http://localhost:8180 -KEYCLOAK_REALM=hyperOTA -KEYCLOAK_CLIENT_ID=hyperota -KEYCLOAK_EXTERNAL_URL=http://localhost:8180 -KEYCLOAK_SECRET=get-secret-from-keycloak -KEYCLOAK_PUBLIC_KEY=get-public-key-from-keycloak +# AuthN provider settings +# AUTHN_PROVIDER can be "keycloak" (default), "oidc", "okta", or "auth0" +AUTHN_PROVIDER=keycloak +# AuthZ provider settings +AUTHZ_PROVIDER=casbin +AUTHZ_BOOTSTRAP_SUPER_ADMINS= +AUTHZ_CASBIN_AUTOLOAD_SECS=60 + +# Comma-separated IdP hints shown in frontend and mapped to provider icons there. +# Example: google,github,microsoft +OIDC_ENABLED_IDPS=google +# Shared OIDC configuration used for the selected AUTHN_PROVIDER +OIDC_ISSUER_URL=http://localhost:8180/realms/hyperOTA +# Optional external issuer/base URL for browser redirects (defaults to OIDC_ISSUER_URL) +OIDC_EXTERNAL_ISSUER_URL=http://localhost:8180/realms/hyperOTA +OIDC_CLIENT_ID=hyperota +OIDC_CLIENT_SECRET=get-secret-from-keycloak +# Allowed JWT clock skew/leeway in seconds for exp/nbf validation. +OIDC_CLOCK_SKEW_SECS=60 + +# Admin API settings used by provider-specific signup flows and migration utilities. +# Runtime Casbin authorization does not require these. +# AUTH_ADMIN_ISSUER must be a Keycloak realm URL: https:///[base-path]/realms/ +AUTH_ADMIN_CLIENT_ID=hyperota +AUTH_ADMIN_CLIENT_SECRET=get-admin-secret-from-keycloak +AUTH_ADMIN_TOKEN_URL=http://localhost:8180/realms/hyperOTA/protocol/openid-connect/token +AUTH_ADMIN_AUDIENCE= +AUTH_ADMIN_SCOPES= +AUTH_ADMIN_ISSUER=http://localhost:8180/realms/hyperOTA # AWS/LocalStack configuration AWS_BUCKET=hyper-ota-bucket @@ -36,6 +58,7 @@ PUBLIC_ENDPOINT=http://localhost:3000 # Configs GOOGLE_SPREADSHEET_ID=1mFqLcqr1pErYe2jc_eLaXjOGlWIGwVgBjoAkVh_P5Rc +# Legacy flag kept for backward compatibility. Prefer OIDC_ENABLED_IDPS. ENABLE_GOOGLE_SIGNIN=false ORGANISATION_CREATION_DISABLED=false GCP_SERVICE_ACCOUNT_PATH=/app/airborne-gcp.json @@ -43,4 +66,6 @@ GCP_SERVICE_ACCOUNT_PATH=/app/airborne-gcp.json RUST_LOG=debug,info,error,actix_web=info,error LOG_FORMAT= SUPERPOSITION_MIGRATION_STRATEGY=PATCH +# Supported values: db, superposition, keycloaktocasbin +# Add keycloaktocasbin to run Keycloak -> Casbin import on startup. MIGRATIONS_TO_RUN_ON_BOOT=db,superposition diff --git a/airborne_server/Cargo.toml b/airborne_server/Cargo.toml index e83745fc..3cafebe3 100644 --- a/airborne_server/Cargo.toml +++ b/airborne_server/Cargo.toml @@ -5,6 +5,8 @@ version.workspace = true edition = "2021" [dependencies] +async-trait = "0.1.89" +airborne_authz_macros = { path = "../airborne_authz_macros" } actix-files = "0.6" actix-multipart = "0.7.2" actix-web = "4" @@ -17,7 +19,9 @@ aws-smithy-runtime-api = { version = "1.2.5", features = ["client"] } aws-smithy-types = "1.2.5" base64 = "0.22" bytes = "1" +casbin = "2.20.0" chrono = { workspace = true } +diesel-adapter = "1.2.0" diesel = { version = "2.2", features = [ "postgres", "r2d2", @@ -36,6 +40,7 @@ http-body = "1.0.1" jsonwebtoken = "9.3.1" keycloak = "=26.1.0" log = "0.4.27" +openidconnect = "4.0.1" r2d2 = "=0.8.10" reqwest = { version = "^0.12.5", features = ["blocking", "stream"] } rustls = { version = "0.23.5" } @@ -45,6 +50,7 @@ sha2 = "0.10" superposition_sdk = "0.99.2" thiserror = { workspace = true } tokio = { workspace = true } +inventory = "0.3" tracing = { workspace = true } tracing-subscriber = { workspace = true } tracing-appender = "=0.2.3" @@ -54,4 +60,4 @@ url = "2.5.7" urlencoding = "2.1.2" uuid = { workspace = true } xxhash-rust = { version = "0.8.15", features = ["xxh64"] } -zip = "4.6.1" \ No newline at end of file +zip = "4.6.1" diff --git a/airborne_server/Project.md b/airborne_server/Project.md index 1947451e..f8842c37 100644 --- a/airborne_server/Project.md +++ b/airborne_server/Project.md @@ -285,14 +285,14 @@ make db-init # in superposition ### 5. Project Structure ``` -dashboard_react/ -├── src/ -│ ├── api/ # API integration -│ ├── components/ # React components -│ ├── store/ # Redux store -│ ├── utils/ # Utility functions -│ ├── types.ts # TypeScript definitions -│ └── main.tsx # Application entry +airborne_dashboard/ +├── app/ # Next.js App Router pages +├── components/ # Reusable UI components +├── hooks/ # Shared React hooks +├── lib/ # API/authz utilities +├── providers/ # App context providers +├── middleware.ts # Next.js middleware +└── next.config.mjs # API rewrite/proxy configuration ``` ### 6. Features @@ -308,7 +308,7 @@ dashboard_react/ #### Local Development ```bash -cd dashboard_react +cd airborne_dashboard npm install npm run dev ``` @@ -320,9 +320,11 @@ npm run build #### Environment Configuration ```env -VITE_API_URL=http://localhost:8000 -VITE_KEYCLOAK_URL=http://localhost:8080 -VITE_CLIENT_ID=your-client-id +BACKEND_URL=http://localhost:8081 +S3_URL=http://localhost:4566/hyper-ota-bucket +# OIDC is configured on airborne_server and exposed to the UI via /dashboard/configuration +OIDC_ISSUER_URL=http://localhost:8180/realms/hyperOTA +OIDC_CLIENT_ID=your-client-id ``` This frontend architecture complements the backend system by providing: @@ -330,4 +332,4 @@ This frontend architecture complements the backend system by providing: - Secure authentication flow - Role-based access control - Real-time release monitoring -- Organization and application management \ No newline at end of file +- Organization and application management diff --git a/airborne_server/README.md b/airborne_server/README.md index 7e1875cf..a9c1553e 100644 --- a/airborne_server/README.md +++ b/airborne_server/README.md @@ -5,11 +5,11 @@ The Airborne Server is a robust backend system designed to power the Software-as ## Key Features - **Multi-Tenant Architecture:** Securely manage multiple organizations and their respective applications. -- **Granular Access Control:** Leverages Keycloak for fine-grained user permissions and roles. +- **Granular Access Control:** Uses a modular AuthZ provider interface with Casbin + PostgreSQL policy storage. - **Flexible Package Management:** Supports versioning and distribution of application packages. - **Dynamic Configuration:** Manage application configurations and release-specific settings. - **Controlled Releases:** Facilitates staged rollouts and management of application releases. -- **Transactional Integrity:** Ensures consistency across distributed operations involving Keycloak, Superposition, and S3. +- **Transactional Integrity:** Ensures consistency across distributed operations involving external services such as Superposition and S3. - **Admin Dashboard:** A React-based user interface for server administration and monitoring. ## Table of Contents @@ -27,7 +27,7 @@ The Airborne Server is a robust backend system designed to power the Software-as - [Public Release Endpoints](#public-release-endpoints) - [Dashboard Access](#dashboard-access) - [Database Architecture](#database-architecture) -- [Keycloak Integration](#keycloak-integration) +- [Auth Providers](#auth-providers) - [Development Environment](#development-environment) - [Prerequisites](#prerequisites) - [Environment Variables](#environment-variables) @@ -40,7 +40,7 @@ The Airborne Server is a robust backend system designed to power the Software-as ## Overview -The Airborne Server acts as the central nervous system for delivering updates to applications. It handles the complexities of storing package assets (via AWS S3), managing configurations (via Superposition and its internal database), and authenticating/authorizing users (via Keycloak). This allows development teams to focus on building features while relying on a stable platform for update distribution. +The Airborne Server acts as the central nervous system for delivering updates to applications. It handles the complexities of storing package assets (via AWS S3), managing configurations (via Superposition and its internal database), and enforcing AuthN/AuthZ with provider-based integrations. This allows development teams to focus on building features while relying on a stable platform for update distribution. **Related Systems:** @@ -48,21 +48,25 @@ The Airborne Server acts as the central nervous system for delivering updates to ## API Reference -All API endpoints are versioned and adhere to RESTful principles. Authentication is primarily handled through JWT Bearer tokens issued by Keycloak. Specific permissions are required for various operations, as detailed below. +All API endpoints are versioned and adhere to RESTful principles. Authentication is handled through OIDC-compatible JWT Bearer tokens (with Keycloak as default). Specific permissions are required for various operations, as detailed below. The base path for all API routes is implicitly defined by the Actix web server configuration in `main.rs`. ### Authentication -Authentication is managed via Keycloak. Most endpoints require a valid JWT Bearer token. +Authentication is OIDC-based and provider-configurable (`AUTHN_PROVIDER=keycloak|oidc|okta|auth0`). +Authorization is provider-based (`AUTHZ_PROVIDER=casbin` in this rollout) and enforced by Casbin policies persisted in PostgreSQL. +Most endpoints require a valid JWT Bearer token. ### User Management Base Path: `/users` (for creation/login), `/user` (for fetching authenticated user details) - **`POST /users/create`**: Registers a new user. - - **Request Body**: `application/json` - `{ "name": "username", "password": "userpassword" }` + - Available only when `AUTHN_PROVIDER=keycloak`. + - **Request Body**: `application/json` - `{ "name": "username", "password": "userpassword", "first_name": "Jane", "last_name": "Doe", "email": "jane@example.com" }` - **Response**: `application/json` - User details including a JWT token. - **`POST /users/login`**: Authenticates an existing user. + - Available only when the configured provider supports password login (Keycloak in v1). - **Request Body**: `application/json` - `{ "name": "username", "password": "userpassword" }` - **Response**: `application/json` - User details including a JWT token. - **`GET /user`**: Retrieves details for the currently authenticated user, including their organizational affiliations. @@ -91,15 +95,15 @@ Base Path: `/organisation/user` (Operations are scoped to the organization conte - **`POST /organisation/user/create`**: Adds a user to the current organization with a specified access role. - **Authentication**: Required (Write permissions for the organization). - - **Request Body**: `application/json` - `{ "user": "username", "access": "read|write|admin|owner" }` + - **Request Body**: `application/json` - `{ "user": "user@example.com", "access": "read|write|admin|owner" }` - **Response**: `application/json` - Success confirmation. - **`POST /organisation/user/update`**: Modifies a user's access role within the current organization. - **Authentication**: Required (Admin permissions for the organization). - - **Request Body**: `application/json` - `{ "user": "username", "access": "read|write|admin|owner" }` + - **Request Body**: `application/json` - `{ "user": "user@example.com", "access": "read|write|admin|owner" }` - **Response**: `application/json` - Success confirmation. - **`POST /organisation/user/remove`**: Removes a user from the current organization. - **Authentication**: Required (Admin permissions for the organization). - - **Request Body**: `application/json` - `{ "user": "username" }` + - **Request Body**: `application/json` - `{ "user": "user@example.com" }` - **Response**: `application/json` - Success confirmation. - **`GET /organisation/user/list`**: Retrieves a list of all users within the current organization, including their roles. - **Authentication**: Required (Read permissions for the organization). @@ -227,7 +231,7 @@ The server utilizes a PostgreSQL database, `airborneserver`, to persist its oper 4. **`cleanup_outbox`**: Facilitates transactional consistency for distributed operations. - - **Purpose**: Implements an outbox pattern to manage rollbacks or retries for operations spanning multiple services (Keycloak, Superposition, S3). + - **Purpose**: Implements an outbox pattern to manage rollbacks or retries for operations spanning multiple services. - **Key Columns**: - `transaction_id` (Text, PK): Unique transaction identifier. - `entity_name` (Text): Identifier of the primary entity involved (e.g., org name). @@ -243,17 +247,21 @@ The server utilizes a PostgreSQL database, `airborneserver`, to persist its oper - `id` (Integer, PK, Auto-increment): Unique internal ID. - `organization_id` (Text): Associated organization ID. - `workspace_name` (Text): The unique workspace name (e.g., "workspace123"). + - `application_id` (Text): Associated application ID (unique with `organization_id`). -## Keycloak Integration +6. **`casbin_rule`**: Authorization policy storage for Casbin. + - **Purpose**: Stores RBAC/ABAC policy rows used by the Casbin enforcer. + - **Key Columns**: + - `ptype`, `v0`..`v5`: Casbin policy tuple columns. -Keycloak is integral to the Airborne Server's security and operational model. It serves the following critical functions: +## Auth Providers -- **Identity and Access Management (IAM)**: Provides robust user authentication (username/password) and manages user identities. -- **Token-Based Authentication**: Issues JSON Web Tokens (JWTs) upon successful login. These tokens are used as Bearer tokens to authenticate API requests to protected endpoints. -- **Authorization and Permissions**: Manages user roles and permissions through a group-based hierarchy. Organizations and applications are represented as groups in Keycloak, with sub-groups defining access levels (e.g., `owner`, `admin`, `write`, `read`). -- **Service Accounts**: Utilized for server-to-server communication between the Airborne Server and Keycloak for administrative tasks like user creation or group management, without requiring user credentials. +Airborne uses separate provider abstractions for authentication and authorization: -The server validates incoming JWTs, extracts user identity and associated permissions (derived from group memberships), and enforces access control rules for all protected resources and operations. +- **AuthN (`AuthNProvider`)**: OIDC-based login/token verification with pluggable providers (`keycloak`, `oidc`, `okta`, `auth0`). +- **AuthZ (`AuthZProvider`)**: Casbin-backed authorization (`AUTHZ_PROVIDER=casbin`) with policies stored in PostgreSQL. +- **Canonical subject**: Authorization uses normalized email as the subject. +- **Keycloak admin APIs**: Used only for provider-specific admin flows such as Keycloak signup and optional migration tooling. ## Development Environment @@ -290,11 +298,28 @@ To set up the development environment for the Airborne Server, you will need the The server relies on a set of environment variables for its configuration. These are typically managed in a `.env` file at the root of the `airborne_server/` directory. Critical variables include: -- `KEYCLOAK_URL`: URL of the Keycloak instance. -- `KEYCLOAK_CLIENT_ID`: Client ID for the Airborne Server in Keycloak. -- `KEYCLOAK_SECRET`: Client secret (typically KMS encrypted for production). -- `KEYCLOAK_REALM`: Keycloak realm name. -- `KEYCLOAK_PUBLIC_KEY`: Public key for validating JWTs issued by Keycloak. +- `AUTHN_PROVIDER`: Authentication provider (`keycloak` by default, or `oidc`/`okta`/`auth0`). +- `AUTHZ_PROVIDER`: Authorization provider (`casbin`). +- `OIDC_ISSUER_URL`: OIDC issuer URL. +- `OIDC_EXTERNAL_ISSUER_URL`: External issuer URL for browser redirects (optional, defaults to `OIDC_ISSUER_URL`). +- `OIDC_CLIENT_ID`: OIDC client ID. +- `OIDC_CLIENT_SECRET`: OIDC client secret. +- `OIDC_ENABLED_IDPS`: Comma-separated identity provider IDs that enable OIDC login/buttons (for example: `keycloak,okta`). + - Leaving this empty keeps OIDC login disabled even if `OIDC_ISSUER_URL` / `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` are set. + - This also affects `/users/oauth/url` endpoints; populate `OIDC_ENABLED_IDPS` to enable those OAuth URL flows. +- `OIDC_CLOCK_SKEW_SECS`: JWT validation leeway (seconds) applied for clock skew on `exp`/`nbf` checks (default: `60`). +- `AUTHZ_BOOTSTRAP_SUPER_ADMINS`: Optional comma-separated emails bootstrapped as super-admins. +- `AUTHZ_CASBIN_POOL_SIZE`: Optional DB connection pool size for Casbin adapter. +- `AUTHZ_CASBIN_AUTOLOAD_SECS`: Optional Casbin policy reload interval. +- `MIGRATIONS_TO_RUN_ON_BOOT`: Optional comma-separated startup jobs. + - Supported values: `db`, `superposition`, `keycloaktocasbin`. + - Use `keycloaktocasbin` to run Keycloak->Casbin authz import during startup. +- `AUTH_ADMIN_CLIENT_ID`: Client ID for provider admin API token acquisition. +- `AUTH_ADMIN_CLIENT_SECRET`: Client secret for provider admin API token acquisition. +- `AUTH_ADMIN_TOKEN_URL`: OAuth token endpoint for provider admin API access tokens. +- `AUTH_ADMIN_AUDIENCE`: Optional audience parameter (commonly used for Auth0). +- `AUTH_ADMIN_SCOPES`: Optional space-separated scopes (commonly used for Okta/Auth0). +- `AUTH_ADMIN_ISSUER`: Issuer URL used to derive Keycloak admin realm/base URL (`.../realms/`). Required for Keycloak signup/import flows. - `SUPERPOSITION_URL`: URL of the Superposition service. - `SUPERPOSITION_ORG_ID`: The organization ID within Superposition used by the server. - `AWS_BUCKET`: Name of the S3 bucket for storing package assets. @@ -325,6 +350,27 @@ Database schema changes are managed using Diesel CLI. - **Automatic Migrations**: The server application is configured to attempt to run any pending migrations automatically upon startup. +### AuthZ Migration (Keycloak -> Casbin) + +Use the one-time import command to convert existing Keycloak group memberships into Casbin policies: + +```bash +cargo run -- authz-import-keycloak --dry-run +cargo run -- authz-import-keycloak --apply +``` + +You can also trigger this via startup migrations env (useful for automation): + +```bash +MIGRATIONS_TO_RUN_ON_BOOT=db,keycloaktocasbin cargo run +``` + +Mapping rules: + +- `/super_admin` -> system super-admin policy +- `/{org}/{role}` -> organisation-scope policy +- `/{org}/{app}/{role}` -> application-scope policy + ### Running the Server The Airborne Server uses a comprehensive Makefile located at the project root for orchestrating the setup and execution of all services. The Makefile provides various commands for different development and deployment scenarios. diff --git a/airborne_server/migrations/20260401120000_add_casbin_rule_and_workspace_uniqueness/down.sql b/airborne_server/migrations/20260401120000_add_casbin_rule_and_workspace_uniqueness/down.sql new file mode 100644 index 00000000..51b96175 --- /dev/null +++ b/airborne_server/migrations/20260401120000_add_casbin_rule_and_workspace_uniqueness/down.sql @@ -0,0 +1,4 @@ +ALTER TABLE hyperotaserver.workspace_names + DROP CONSTRAINT IF EXISTS workspace_names_org_app_unique; + +DROP TABLE IF EXISTS public.casbin_rule; diff --git a/airborne_server/migrations/20260401120000_add_casbin_rule_and_workspace_uniqueness/up.sql b/airborne_server/migrations/20260401120000_add_casbin_rule_and_workspace_uniqueness/up.sql new file mode 100644 index 00000000..fdb8d93b --- /dev/null +++ b/airborne_server/migrations/20260401120000_add_casbin_rule_and_workspace_uniqueness/up.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS public.casbin_rule ( + id SERIAL PRIMARY KEY, + ptype VARCHAR(255) NOT NULL, + v0 VARCHAR(255), + v1 VARCHAR(255), + v2 VARCHAR(255), + v3 VARCHAR(255), + v4 VARCHAR(255), + v5 VARCHAR(255) +); + +DELETE FROM hyperotaserver.workspace_names older +USING hyperotaserver.workspace_names newer +WHERE older.id < newer.id + AND older.organization_id = newer.organization_id + AND older.application_id = newer.application_id; + +ALTER TABLE hyperotaserver.workspace_names + ADD CONSTRAINT workspace_names_org_app_unique UNIQUE (organization_id, application_id); diff --git a/airborne_server/migrations/20260409193000_add_authz_bindings_and_memberships/down.sql b/airborne_server/migrations/20260409193000_add_authz_bindings_and_memberships/down.sql new file mode 100644 index 00000000..2e276b23 --- /dev/null +++ b/airborne_server/migrations/20260409193000_add_authz_bindings_and_memberships/down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS hyperotaserver.authz_memberships; +DROP TABLE IF EXISTS hyperotaserver.authz_role_bindings; diff --git a/airborne_server/migrations/20260409193000_add_authz_bindings_and_memberships/up.sql b/airborne_server/migrations/20260409193000_add_authz_bindings_and_memberships/up.sql new file mode 100644 index 00000000..6fc47e7e --- /dev/null +++ b/airborne_server/migrations/20260409193000_add_authz_bindings_and_memberships/up.sql @@ -0,0 +1,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); diff --git a/airborne_server/migrations/20260410120000_add_service_accounts/down.sql b/airborne_server/migrations/20260410120000_add_service_accounts/down.sql new file mode 100644 index 00000000..6c6cc275 --- /dev/null +++ b/airborne_server/migrations/20260410120000_add_service_accounts/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS hyperotaserver.service_accounts; diff --git a/airborne_server/migrations/20260410120000_add_service_accounts/up.sql b/airborne_server/migrations/20260410120000_add_service_accounts/up.sql new file mode 100644 index 00000000..6679c2a9 --- /dev/null +++ b/airborne_server/migrations/20260410120000_add_service_accounts/up.sql @@ -0,0 +1,13 @@ +CREATE TABLE hyperotaserver.service_accounts ( + client_id UUID PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + organisation TEXT NOT NULL, + created_by TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (email), + UNIQUE (organisation, name) +); + +CREATE INDEX idx_service_accounts_org ON hyperotaserver.service_accounts (organisation); diff --git a/airborne_server/scripts/encrypt-envs.sh b/airborne_server/scripts/encrypt-envs.sh index 75dd51a2..66ab7de3 100755 --- a/airborne_server/scripts/encrypt-envs.sh +++ b/airborne_server/scripts/encrypt-envs.sh @@ -101,7 +101,8 @@ encrypt_master_key_with_kms() { SECRETS=( "DB_PASSWORD" "DB_MIGRATION_PASSWORD" - "KEYCLOAK_SECRET" + "OIDC_CLIENT_SECRET" + "AUTH_ADMIN_CLIENT_SECRET" "SUPERPOSITION_TOKEN" "SUPERPOSITION_USER_TOKEN" "SUPERPOSITION_ORG_TOKEN" diff --git a/airborne_server/scripts/init-keycloak.sh b/airborne_server/scripts/init-keycloak.sh index 214e2ef6..dd903197 100755 --- a/airborne_server/scripts/init-keycloak.sh +++ b/airborne_server/scripts/init-keycloak.sh @@ -19,73 +19,133 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PYTHON_DEPS_DIR="${SCRIPT_DIR}/.python-tools" PYTHON_BIN="" +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' + if [ -f .env ]; then set -a . .env set +a fi -# Check if encryption is enabled USE_ENCRYPTION="${USE_ENCRYPTED_SECRETS:-true}" MASTERKEY_FILE=".masterkey.local" ADMIN_USERNAME=admin ADMIN_PASSWORD=admin -echo "${YELLOW}🔑 Keycloak Host:${NC} ${GREEN}${KEYCLOAK_URL}${NC}" -echo "${YELLOW}🏛️ Realm:${NC} ${GREEN}${KEYCLOAK_REALM}${NC}" -echo "${YELLOW}🆔 Client ID:${NC} ${GREEN}${KEYCLOAK_CLIENT_ID}${NC}" +if [ -z "${OIDC_ISSUER_URL}" ]; then + if [ -n "${AUTH_ADMIN_ISSUER}" ]; then + OIDC_ISSUER_URL="${AUTH_ADMIN_ISSUER}" + elif [ -n "${KEYCLOAK_URL}" ] && [ -n "${KEYCLOAK_REALM}" ]; then + OIDC_ISSUER_URL="${KEYCLOAK_URL%/}/realms/${KEYCLOAK_REALM}" + fi +fi + +if [ -z "${OIDC_CLIENT_ID}" ] && [ -n "${KEYCLOAK_CLIENT_ID}" ]; then + OIDC_CLIENT_ID="${KEYCLOAK_CLIENT_ID}" +fi + +if [ -z "${OIDC_ISSUER_URL}" ]; then + echo "${RED}ERROR: OIDC_ISSUER_URL must be set (or provide AUTH_ADMIN_ISSUER / KEYCLOAK_URL+KEYCLOAK_REALM for fallback)${NC}" + exit 1 +fi + +if [ -z "${OIDC_CLIENT_ID}" ]; then + echo "${RED}ERROR: OIDC_CLIENT_ID must be set (or provide KEYCLOAK_CLIENT_ID for fallback)${NC}" + exit 1 +fi + +if [ -z "${AUTH_ADMIN_CLIENT_ID}" ]; then + AUTH_ADMIN_CLIENT_ID="${OIDC_CLIENT_ID}" +fi -echo "${YELLOW}🎫 Getting admin token...${NC}" -ADMIN_TOKEN_RESPONSE=$(curl -v -s -X POST "${KEYCLOAK_URL}/realms/master/protocol/openid-connect/token" \ +ISSUER_TRIMMED="${OIDC_ISSUER_URL%/}" +if ! printf '%s' "$ISSUER_TRIMMED" | grep -q "/realms/"; then + echo "${RED}ERROR: OIDC_ISSUER_URL must contain /realms/{realm}${NC}" + exit 1 +fi + +KC_BASE_URL="${ISSUER_TRIMMED%/realms/*}" +KC_REALM="${ISSUER_TRIMMED##*/realms/}" +KC_REALM="${KC_REALM%%/*}" + +if [ -z "${AUTH_ADMIN_ISSUER}" ]; then + AUTH_ADMIN_ISSUER="${ISSUER_TRIMMED}" +fi +if [ -z "${AUTH_ADMIN_TOKEN_URL}" ]; then + AUTH_ADMIN_TOKEN_URL="${KC_BASE_URL}/realms/${KC_REALM}/protocol/openid-connect/token" +fi + +echo "${YELLOW}Keycloak host:${NC} ${GREEN}${KC_BASE_URL}${NC}" +echo "${YELLOW}Realm:${NC} ${GREEN}${KC_REALM}${NC}" +echo "${YELLOW}OIDC client id:${NC} ${GREEN}${OIDC_CLIENT_ID}${NC}" +echo "${YELLOW}Admin client id:${NC} ${GREEN}${AUTH_ADMIN_CLIENT_ID}${NC}" + +echo "${YELLOW}Getting admin token...${NC}" +ADMIN_TOKEN_RESPONSE=$(curl -s -X POST "${KC_BASE_URL}/realms/master/protocol/openid-connect/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "username=${ADMIN_USERNAME}" \ -d "password=${ADMIN_PASSWORD}" \ -d "grant_type=password" \ -d "client_id=admin-cli") -# echo "${YELLOW}📡 Token response:${NC} $ADMIN_TOKEN_RESPONSE" ADMIN_TOKEN=$(echo "$ADMIN_TOKEN_RESPONSE" | grep -o '"access_token":"[^"]*' | cut -d'"' -f4) if [ -z "$ADMIN_TOKEN" ]; then - echo "${RED}❌ ERROR: Failed to get admin token${NC}" + echo "${RED}ERROR: Failed to get admin token${NC}" exit 1 fi -echo "${GREEN}✅ Successfully got admin token!${NC}" -echo "${YELLOW}🔍 Getting client UUID for client ID: ${KEYCLOAK_CLIENT_ID}...${NC}" -CLIENT_LIST_RESPONSE=$(curl -s "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients" \ +echo "${YELLOW}Fetching clients from Keycloak...${NC}" +CLIENT_LIST_RESPONSE=$(curl -s "${KC_BASE_URL}/admin/realms/${KC_REALM}/clients" \ -H "Authorization: Bearer ${ADMIN_TOKEN}") -# echo "${YELLOW}📋 Client list response:${NC} $CLIENT_LIST_RESPONSE" -CLIENT_UUID=$(echo "$CLIENT_LIST_RESPONSE" | jq -r --arg cid "$KEYCLOAK_CLIENT_ID" '.[] | select(.clientId == $cid) | .id') -if [ -z "$CLIENT_UUID" ]; then - echo "${RED}❌ ERROR: Failed to get client UUID for client: ${KEYCLOAK_CLIENT_ID}${NC}" - exit 1 -fi -echo "${GREEN}✅ Successfully got client UUID: $CLIENT_UUID${NC}" +get_client_uuid() { + local client_id="$1" + echo "$CLIENT_LIST_RESPONSE" | jq -r --arg cid "$client_id" '.[] | select(.clientId == $cid) | .id' +} -echo "${YELLOW}🔐 Getting client secret...${NC}" -SECRET_RESPONSE=$(curl -s "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients/${CLIENT_UUID}/client-secret" \ - -H "Authorization: Bearer ${ADMIN_TOKEN}") -echo "${YELLOW}🔒 Secret response:${NC} $SECRET_RESPONSE" +get_client_secret() { + local client_id="$1" + local client_uuid + local secret_response + local client_secret -CLIENT_SECRET=$(echo "$SECRET_RESPONSE" | jq -r '.value // empty') -if [ -z "$CLIENT_SECRET" ]; then - CLIENT_SECRET=$(echo "$SECRET_RESPONSE" | sed -n 's/.*"value"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') -fi -echo "${YELLOW}🔑 Using client secret:${NC} ${GREEN}$CLIENT_SECRET${NC}" + client_uuid=$(get_client_uuid "$client_id") + if [ -z "$client_uuid" ] || [ "$client_uuid" = "null" ]; then + echo "${RED}ERROR: Failed to find client UUID for client: ${client_id}${NC}" >&2 + return 1 + fi -echo "${YELLOW}🗝️ Getting realm public key...${NC}" -REALM_RESPONSE=$(curl -s "${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}") -# echo "${YELLOW}🏛️ Realm response:${NC} $REALM_RESPONSE" + secret_response=$(curl -s "${KC_BASE_URL}/admin/realms/${KC_REALM}/clients/${client_uuid}/client-secret" \ + -H "Authorization: Bearer ${ADMIN_TOKEN}") + + client_secret=$(echo "$secret_response" | jq -r '.value // empty') + if [ -z "$client_secret" ]; then + client_secret=$(echo "$secret_response" | sed -n 's/.*"value"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') + fi -PUBLIC_KEY=$(echo "$REALM_RESPONSE" | grep -o '"public_key":"[^"]*' | cut -d'"' -f4) -echo "${GREEN}✅ Successfully got realm public key!${NC}" + if [ -z "$client_secret" ]; then + echo "${RED}ERROR: Failed to read client secret for client: ${client_id}${NC}" >&2 + return 1 + fi + + echo "$client_secret" +} + +OIDC_CLIENT_SECRET_VALUE=$(get_client_secret "$OIDC_CLIENT_ID") +if [ "$AUTH_ADMIN_CLIENT_ID" = "$OIDC_CLIENT_ID" ]; then + AUTH_ADMIN_CLIENT_SECRET_VALUE="$OIDC_CLIENT_SECRET_VALUE" +else + AUTH_ADMIN_CLIENT_SECRET_VALUE=$(get_client_secret "$AUTH_ADMIN_CLIENT_ID") +fi portable_sed_inplace() { local pattern="$1" local file="$2" - + if sed --version >/dev/null 2>&1; then sed -i "$pattern" "$file" else @@ -104,7 +164,7 @@ ensure_python_crypto() { fi if [ -z "$PYTHON_BIN" ]; then - echo "${RED}❌ ERROR: No Python interpreter with SSL support was found${NC}" >&2 + echo "${RED}ERROR: No Python interpreter with SSL support was found${NC}" >&2 exit 1 fi @@ -116,14 +176,14 @@ ensure_python_crypto() { return 0 fi - echo "${YELLOW}📦 Installing Python cryptography dependency...${NC}" + echo "${YELLOW}Installing Python cryptography dependency...${NC}" mkdir -p "$PYTHON_DEPS_DIR" "$PYTHON_BIN" -m pip install --quiet --target "$PYTHON_DEPS_DIR" cryptography } shell_quote() { local value="$1" - printf "'%s'" "$(printf '%s' "$value" | sed "s/'/'\\\\''/g")" + printf "'%s'" "$(printf '%s' "$value" | sed "s/'/'\\''/g")" } upsert_env_var() { @@ -150,56 +210,60 @@ upsert_env_var() { mv "$tmp_file" "$file" } -# Update KEYCLOAK_PUBLIC_KEY (never encrypted) -if grep -q "^KEYCLOAK_PUBLIC_KEY=" ".env"; then - portable_sed_inplace "s|^KEYCLOAK_PUBLIC_KEY=.*|KEYCLOAK_PUBLIC_KEY=$PUBLIC_KEY|" ".env" -else - echo "KEYCLOAK_PUBLIC_KEY=$PUBLIC_KEY" >> ".env" +if [ ! -f .env.generated ]; then + touch .env.generated fi -# Handle KEYCLOAK_SECRET based on encryption mode +upsert_env_var ".env" "AUTH_ADMIN_CLIENT_ID" "$AUTH_ADMIN_CLIENT_ID" +upsert_env_var ".env" "AUTH_ADMIN_TOKEN_URL" "$AUTH_ADMIN_TOKEN_URL" +upsert_env_var ".env" "AUTH_ADMIN_ISSUER" "$AUTH_ADMIN_ISSUER" +upsert_env_var ".env" "OIDC_ISSUER_URL" "$OIDC_ISSUER_URL" +upsert_env_var ".env" "OIDC_CLIENT_ID" "$OIDC_CLIENT_ID" + +upsert_env_var ".env.generated" "AUTH_ADMIN_CLIENT_ID" "$AUTH_ADMIN_CLIENT_ID" +upsert_env_var ".env.generated" "AUTH_ADMIN_TOKEN_URL" "$AUTH_ADMIN_TOKEN_URL" +upsert_env_var ".env.generated" "AUTH_ADMIN_ISSUER" "$AUTH_ADMIN_ISSUER" +upsert_env_var ".env.generated" "OIDC_ISSUER_URL" "$OIDC_ISSUER_URL" +upsert_env_var ".env.generated" "OIDC_CLIENT_ID" "$OIDC_CLIENT_ID" + if [ "$USE_ENCRYPTION" = "true" ]; then - echo "${YELLOW}🔐 Encryption enabled - encrypting KEYCLOAK_SECRET...${NC}" + echo "${YELLOW}Encryption enabled, encrypting auth client secrets...${NC}" ensure_python_crypto - - # Check if master key exists + if [ ! -f "$MASTERKEY_FILE" ]; then - echo "${YELLOW}Generating new master key...${NC}" MASTER_KEY=$(openssl rand -hex 32) echo "$MASTER_KEY" > "$MASTERKEY_FILE" chmod 600 "$MASTERKEY_FILE" - echo "${GREEN}✅ Master key saved to $MASTERKEY_FILE${NC}" else MASTER_KEY=$(cat "$MASTERKEY_FILE") - echo "${GREEN}✅ Using existing master key${NC}" - fi - - ENCRYPTED_VALUE=$(printf '%s' "$CLIENT_SECRET" | PYTHONPATH="$PYTHON_DEPS_DIR${PYTHONPATH:+:$PYTHONPATH}" \ - "$PYTHON_BIN" "$SCRIPT_DIR/aes_gcm_encrypt.py" "$MASTER_KEY") - - # Update .env with encrypted value - upsert_env_var ".env" "KEYCLOAK_SECRET" "$ENCRYPTED_VALUE" - - # Also save to .env.generated for reference - if [ ! -f .env.generated ]; then - touch .env.generated fi - upsert_env_var ".env.generated" "KEYCLOAK_SECRET" "$CLIENT_SECRET" - - echo "${GREEN}✅ KEYCLOAK_SECRET encrypted and added to .env${NC}" + + encrypt_value() { + local value="$1" + printf '%s' "$value" | PYTHONPATH="$PYTHON_DEPS_DIR${PYTHONPATH:+:$PYTHONPATH}" \ + "$PYTHON_BIN" "$SCRIPT_DIR/aes_gcm_encrypt.py" "$MASTER_KEY" + } + + OIDC_CLIENT_SECRET_ENCRYPTED=$(encrypt_value "$OIDC_CLIENT_SECRET_VALUE") + AUTH_ADMIN_CLIENT_SECRET_ENCRYPTED=$(encrypt_value "$AUTH_ADMIN_CLIENT_SECRET_VALUE") + + upsert_env_var ".env" "OIDC_CLIENT_SECRET" "$OIDC_CLIENT_SECRET_ENCRYPTED" + upsert_env_var ".env" "AUTH_ADMIN_CLIENT_SECRET" "$AUTH_ADMIN_CLIENT_SECRET_ENCRYPTED" + + upsert_env_var ".env.generated" "OIDC_CLIENT_SECRET" "$OIDC_CLIENT_SECRET_VALUE" + upsert_env_var ".env.generated" "AUTH_ADMIN_CLIENT_SECRET" "$AUTH_ADMIN_CLIENT_SECRET_VALUE" + + echo "${GREEN}Encrypted OIDC_CLIENT_SECRET and AUTH_ADMIN_CLIENT_SECRET${NC}" else - echo "${YELLOW}📝 Encryption disabled - saving plaintext KEYCLOAK_SECRET...${NC}" - - # Save plaintext to .env - upsert_env_var ".env" "KEYCLOAK_SECRET" "$CLIENT_SECRET" - - # Also save to .env.generated - if [ ! -f .env.generated ]; then - touch .env.generated - fi - upsert_env_var ".env.generated" "KEYCLOAK_SECRET" "$CLIENT_SECRET" - - echo "${GREEN}✅ KEYCLOAK_SECRET saved as plaintext${NC}" + echo "${YELLOW}Encryption disabled, writing plaintext auth client secrets...${NC}" + + upsert_env_var ".env" "OIDC_CLIENT_SECRET" "$OIDC_CLIENT_SECRET_VALUE" + upsert_env_var ".env" "AUTH_ADMIN_CLIENT_SECRET" "$AUTH_ADMIN_CLIENT_SECRET_VALUE" + + upsert_env_var ".env.generated" "OIDC_CLIENT_SECRET" "$OIDC_CLIENT_SECRET_VALUE" + upsert_env_var ".env.generated" "AUTH_ADMIN_CLIENT_SECRET" "$AUTH_ADMIN_CLIENT_SECRET_VALUE" + + echo "${GREEN}Saved plaintext OIDC_CLIENT_SECRET and AUTH_ADMIN_CLIENT_SECRET${NC}" fi -echo "${GREEN}✅ Successfully added the ENVs to .env and .env.generated file!${NC}" +echo "${GREEN}Updated auth env values in .env and .env.generated${NC}" diff --git a/airborne_server/scripts/init-localstack.sh b/airborne_server/scripts/init-localstack.sh index 1b7c379d..c94a8d8b 100755 --- a/airborne_server/scripts/init-localstack.sh +++ b/airborne_server/scripts/init-localstack.sh @@ -162,7 +162,7 @@ aws --endpoint-url=${AWS_ENDPOINT_URL} s3 mb s3://$AWS_BUCKET >/dev/null 2>&1 || echo "${GREEN}✅ S3 bucket ready: $AWS_BUCKET${NC}" # Variables that need encryption/processing -SENSITIVE_VARS=("DB_PASSWORD" "DB_MIGRATION_PASSWORD" "KEYCLOAK_SECRET") +SENSITIVE_VARS=("DB_PASSWORD" "DB_MIGRATION_PASSWORD" "OIDC_CLIENT_SECRET" "AUTH_ADMIN_CLIENT_SECRET") # Get values from .env.example or .env.generated get_value() { @@ -248,8 +248,8 @@ if [ "$USE_ENCRYPTION" = "true" ]; then VALUE=$(get_value "$var") if [ -n "$VALUE" ]; then - # Skip KEYCLOAK_SECRET if already handled by init-keycloak.sh - if [ "$var" = "KEYCLOAK_SECRET" ]; then + # Skip auth secrets if already handled by init-keycloak.sh + if [ "$var" = "OIDC_CLIENT_SECRET" ] || [ "$var" = "AUTH_ADMIN_CLIENT_SECRET" ]; then # Check if already encrypted in .env CURRENT_VAL=$(grep "^${var}=" ".env" 2>/dev/null | cut -d'=' -f2- | head -1) CURRENT_VAL=$(strip_shell_quotes "$CURRENT_VAL") @@ -286,8 +286,8 @@ else # Copy plaintext values from .env.example if not present for var in "${SENSITIVE_VARS[@]}"; do - # Skip KEYCLOAK_SECRET (handled by init-keycloak.sh) - if [ "$var" = "KEYCLOAK_SECRET" ]; then + # Skip auth secrets (handled by init-keycloak.sh) + if [ "$var" = "OIDC_CLIENT_SECRET" ] || [ "$var" = "AUTH_ADMIN_CLIENT_SECRET" ]; then continue fi diff --git a/airborne_server/src/authz.rs b/airborne_server/src/authz.rs new file mode 100644 index 00000000..f76dafef --- /dev/null +++ b/airborne_server/src/authz.rs @@ -0,0 +1,339 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use actix_web::{ + get, post, + web::{self, Json, Query, ReqData}, + Scope, +}; + +use crate::{ + middleware::auth::AuthResponse, + provider::authz::{permission::EndpointPermissionBinding, AuthzPermissionCheck}, + types as airborne_types, + types::{ABError, AppState}, +}; + +use self::types::{ + EnforceBatchRequest, EnforceBatchResponse, PermissionBatchCheckResult, PermissionCatalogItem, + PermissionCatalogQuery, PermissionCatalogResponse, +}; + +pub mod types; + +const SCOPE_ORG: &str = "org"; +const SCOPE_APP: &str = "app"; +const SCOPE_AUTO: &str = "auto"; +const MAX_BATCH_CHECKS: usize = 200; + +#[derive(Copy, Clone, Debug)] +enum PermissionScope { + Organisation, + Application, + Auto, +} + +impl PermissionScope { + fn as_str(self) -> &'static str { + match self { + Self::Organisation => SCOPE_ORG, + Self::Application => SCOPE_APP, + Self::Auto => SCOPE_AUTO, + } + } +} + +#[derive(Debug)] +struct PendingDecision { + resource: String, + action: String, + scope: String, + decision_indexes: Vec, +} + +pub fn add_routes() -> Scope { + Scope::new("") + .service(permission_catalog) + .service(enforce_my_permissions_batch) +} + +#[get("/catalog")] +async fn permission_catalog( + auth_response: ReqData, + query: Query, +) -> airborne_types::Result> { + let auth = auth_response.into_inner(); + let scope = resolve_catalog_scope(query.scope.as_deref(), &auth)?; + + let mut dedup = BTreeSet::<(String, String)>::new(); + for binding in inventory::iter:: { + let resource = binding.resource.trim().to_ascii_lowercase(); + let action = binding.action.trim().to_ascii_lowercase(); + if resource.is_empty() || action.is_empty() { + continue; + } + if !is_valid_permission_part(&resource) || !is_valid_permission_part(&action) { + continue; + } + + let include = match scope { + PermissionScope::Organisation => binding.allow_org, + PermissionScope::Application => binding.allow_app, + PermissionScope::Auto => false, + }; + if include { + dedup.insert((resource, action)); + } + } + + let permissions = dedup + .into_iter() + .map(|(resource, action)| PermissionCatalogItem { + key: format!("{resource}.{action}"), + resource, + action, + scope: scope.as_str().to_string(), + }) + .collect::>(); + + Ok(Json(PermissionCatalogResponse { permissions })) +} + +#[post("/me/enforce-batch")] +async fn enforce_my_permissions_batch( + auth_response: ReqData, + body: Json, + state: web::Data, +) -> airborne_types::Result> { + let auth = auth_response.into_inner(); + let payload = body.into_inner(); + if payload.checks.is_empty() { + return Err(ABError::BadRequest("checks cannot be empty".to_string())); + } + if payload.checks.len() > MAX_BATCH_CHECKS { + return Err(ABError::BadRequest(format!( + "checks exceeds limit of {}", + MAX_BATCH_CHECKS + ))); + } + + let org_name = auth.organisation.as_ref().map(|value| value.name.clone()); + let app_name = auth.application.as_ref().map(|value| value.name.clone()); + + let mut provider_checks = Vec::with_capacity(payload.checks.len().min(MAX_BATCH_CHECKS)); + let mut check_index = BTreeMap::new(); + let mut result_context = Vec::with_capacity(payload.checks.len().min(MAX_BATCH_CHECKS)); + for check in payload.checks { + let resource = normalize_permission_part(&check.resource, "resource")?; + let action = normalize_permission_part(&check.action, "action")?; + let scope = parse_requested_scope(check.scope.as_deref())?; + let mut decision_indexes = Vec::new(); + match scope { + PermissionScope::Organisation => { + let organisation = + required_context_value(org_name.as_deref(), "organisation", "x-organisation")?; + let index = upsert_provider_check( + &mut provider_checks, + &mut check_index, + organisation, + None, + &resource, + &action, + ); + decision_indexes.push(index); + } + PermissionScope::Application => { + let organisation = + required_context_value(org_name.as_deref(), "organisation", "x-organisation")?; + let application = + required_context_value(app_name.as_deref(), "application", "x-application")?; + let index = upsert_provider_check( + &mut provider_checks, + &mut check_index, + organisation, + Some(application), + &resource, + &action, + ); + decision_indexes.push(index); + } + PermissionScope::Auto => { + let organisation = + required_context_value(org_name.as_deref(), "organisation", "x-organisation")?; + if let Some(application) = app_name.as_deref() { + // In app context, mirror endpoint enforcement semantics: allow app OR org. + let app_index = upsert_provider_check( + &mut provider_checks, + &mut check_index, + organisation, + Some(application), + &resource, + &action, + ); + decision_indexes.push(app_index); + } + let org_index = upsert_provider_check( + &mut provider_checks, + &mut check_index, + organisation, + None, + &resource, + &action, + ); + decision_indexes.push(org_index); + } + } + result_context.push(PendingDecision { + resource, + action, + scope: scope.as_str().to_string(), + decision_indexes, + }); + } + + let decisions = state + .authz_provider + .enforce_permissions_batch(state.get_ref(), &auth.sub, &provider_checks) + .await?; + + if decisions.len() != provider_checks.len() { + return Err(ABError::InternalServerError( + "Permission evaluation size mismatch".to_string(), + )); + } + + let results = result_context + .into_iter() + .map(|entry| { + let mut allowed = false; + for index in entry.decision_indexes { + let Some(value) = decisions.get(index) else { + return Err(ABError::InternalServerError( + "Permission evaluation index mismatch".to_string(), + )); + }; + if *value { + allowed = true; + break; + } + } + Ok(PermissionBatchCheckResult { + key: format!("{}.{}", entry.resource, entry.action), + resource: entry.resource, + action: entry.action, + scope: entry.scope, + allowed, + }) + }) + .collect::>>()?; + + Ok(Json(EnforceBatchResponse { results })) +} + +fn parse_requested_scope(raw_scope: Option<&str>) -> airborne_types::Result { + let normalized = raw_scope.map(|value| value.trim().to_ascii_lowercase()); + match normalized.as_deref() { + Some("org") | Some("organisation") => Ok(PermissionScope::Organisation), + Some("app") | Some("application") => Ok(PermissionScope::Application), + Some("auto") | None => Ok(PermissionScope::Auto), + Some(value) => Err(ABError::BadRequest(format!( + "Invalid scope '{}'. Expected one of: org, app, auto", + value + ))), + } +} + +fn resolve_catalog_scope( + raw_scope: Option<&str>, + auth: &AuthResponse, +) -> airborne_types::Result { + match parse_requested_scope(raw_scope)? { + PermissionScope::Organisation => { + if auth.organisation.is_some() { + Ok(PermissionScope::Organisation) + } else { + Err(ABError::BadRequest( + "Organisation scope requires x-organisation header".to_string(), + )) + } + } + PermissionScope::Application => { + if auth.organisation.is_some() && auth.application.is_some() { + Ok(PermissionScope::Application) + } else { + Err(ABError::BadRequest( + "Application scope requires x-organisation and x-application headers" + .to_string(), + )) + } + } + PermissionScope::Auto => { + if auth.application.is_some() { + Ok(PermissionScope::Application) + } else if auth.organisation.is_some() { + Ok(PermissionScope::Organisation) + } else { + Err(ABError::BadRequest( + "Scope context missing. Send x-organisation (and x-application for app scope)" + .to_string(), + )) + } + } + } +} + +fn normalize_permission_part(raw: &str, field: &str) -> airborne_types::Result { + let normalized = raw.trim().to_ascii_lowercase(); + if normalized.is_empty() { + return Err(ABError::BadRequest(format!("{field} cannot be empty"))); + } + if !is_valid_permission_part(&normalized) { + return Err(ABError::BadRequest(format!( + "Invalid {} '{}'. Use lowercase slug values [a-z0-9_-]", + field, raw + ))); + } + Ok(normalized) +} + +fn required_context_value<'a>( + value: Option<&'a str>, + name: &str, + header: &str, +) -> airborne_types::Result<&'a str> { + value.ok_or_else(|| ABError::BadRequest(format!("{name} context is missing; send {header}"))) +} + +fn upsert_provider_check( + provider_checks: &mut Vec, + check_index: &mut BTreeMap<(String, Option, String, String), usize>, + organisation: &str, + application: Option<&str>, + resource: &str, + action: &str, +) -> usize { + let key = ( + organisation.to_string(), + application.map(str::to_string), + resource.to_string(), + action.to_string(), + ); + if let Some(existing) = check_index.get(&key) { + return *existing; + } + + let index = provider_checks.len(); + provider_checks.push(AuthzPermissionCheck { + organisation: organisation.to_string(), + application: application.map(str::to_string), + resource: resource.to_string(), + action: action.to_string(), + }); + check_index.insert(key, index); + index +} + +fn is_valid_permission_part(value: &str) -> bool { + value + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_' || ch == '-') +} diff --git a/airborne_server/src/authz/types.rs b/airborne_server/src/authz/types.rs new file mode 100644 index 00000000..2664bd8d --- /dev/null +++ b/airborne_server/src/authz/types.rs @@ -0,0 +1,45 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize)] +pub struct PermissionCatalogQuery { + pub scope: Option, +} + +#[derive(Debug, Serialize)] +pub struct PermissionCatalogItem { + pub key: String, + pub resource: String, + pub action: String, + pub scope: String, +} + +#[derive(Debug, Serialize)] +pub struct PermissionCatalogResponse { + pub permissions: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct PermissionBatchCheckRequest { + pub resource: String, + pub action: String, + pub scope: Option, +} + +#[derive(Debug, Deserialize)] +pub struct EnforceBatchRequest { + pub checks: Vec, +} + +#[derive(Debug, Serialize)] +pub struct PermissionBatchCheckResult { + pub key: String, + pub resource: String, + pub action: String, + pub scope: String, + pub allowed: bool, +} + +#[derive(Debug, Serialize)] +pub struct EnforceBatchResponse { + pub results: Vec, +} diff --git a/airborne_server/src/config.rs b/airborne_server/src/config.rs index b23d5f69..467b5196 100644 --- a/airborne_server/src/config.rs +++ b/airborne_server/src/config.rs @@ -14,6 +14,7 @@ use crate::utils::kms::{decrypt_env, decrypt_master_key}; use aws_sdk_kms::Client; +use std::collections::HashSet; use std::env; use std::str::FromStr; @@ -42,13 +43,22 @@ pub struct AppConfig { pub aws_bucket: String, pub aws_endpoint_url: Option, - // Keycloak settings - pub keycloak_url: String, - pub keycloak_external_url: String, - pub keycloak_realm: String, - pub keycloak_client_id: String, - pub keycloak_secret: String, - pub keycloak_public_key: String, + // Authentication provider settings + pub authn_provider: String, + pub authz_provider: String, + pub oidc_issuer_url: Option, + pub oidc_external_issuer_url: Option, + pub oidc_client_id: Option, + pub oidc_client_secret: Option, + pub oidc_clock_skew_secs: u64, + pub authz_bootstrap_super_admins: Option, + pub authz_casbin_auto_load_secs: Option, + pub auth_admin_client_id: Option, + pub auth_admin_client_secret: Option, + pub auth_admin_token_url: Option, + pub auth_admin_audience: Option, + pub auth_admin_scopes: Option, + pub auth_admin_issuer: Option, // Superposition settings pub superposition_url: String, @@ -59,7 +69,7 @@ pub struct AppConfig { pub enable_authenticated_superposition: bool, // Feature flags - pub enable_google_signin: bool, + pub enabled_oidc_idps: Vec, pub organisation_creation_disabled: bool, // Google Sheets @@ -135,6 +145,17 @@ impl AppConfig { let get_optional = |name: &str| -> Option { env::var(name).ok().filter(|v| !v.is_empty()) }; + let legacy_google_signin_enabled: bool = parse_env("ENABLE_GOOGLE_SIGNIN", false); + let enabled_oidc_idps = get_optional("OIDC_ENABLED_IDPS") + .map(|raw| parse_csv_env_list(&raw)) + .unwrap_or_else(|| { + if legacy_google_signin_enabled { + vec!["google".to_string()] + } else { + Vec::new() + } + }); + Ok(AppConfig { // Server settings port: parse_env("PORT", 8081), @@ -159,13 +180,24 @@ impl AppConfig { aws_bucket: get_env("AWS_BUCKET", None)?, aws_endpoint_url: get_optional("AWS_ENDPOINT_URL"), - // Keycloak settings - keycloak_url: get_env("KEYCLOAK_URL", None)?, - keycloak_external_url: get_env("KEYCLOAK_EXTERNAL_URL", Some("localhost:8180"))?, - keycloak_realm: get_env("KEYCLOAK_REALM", None)?, - keycloak_client_id: get_env("KEYCLOAK_CLIENT_ID", None)?, - keycloak_secret: get_secret("KEYCLOAK_SECRET")?, - keycloak_public_key: get_env("KEYCLOAK_PUBLIC_KEY", None)?, + // Authentication provider settings + authn_provider: get_env("AUTHN_PROVIDER", Some("keycloak"))?, + authz_provider: get_env("AUTHZ_PROVIDER", Some("casbin"))?, + oidc_issuer_url: get_optional("OIDC_ISSUER_URL"), + oidc_external_issuer_url: get_optional("OIDC_EXTERNAL_ISSUER_URL"), + oidc_client_id: get_optional("OIDC_CLIENT_ID"), + oidc_client_secret: get_optional_secret("OIDC_CLIENT_SECRET")?, + oidc_clock_skew_secs: parse_env("OIDC_CLOCK_SKEW_SECS", 60), + authz_bootstrap_super_admins: get_optional("AUTHZ_BOOTSTRAP_SUPER_ADMINS"), + authz_casbin_auto_load_secs: env::var("AUTHZ_CASBIN_AUTOLOAD_SECS") + .ok() + .and_then(|value| value.parse::().ok()), + auth_admin_client_id: get_optional("AUTH_ADMIN_CLIENT_ID"), + auth_admin_client_secret: get_optional_secret("AUTH_ADMIN_CLIENT_SECRET")?, + auth_admin_token_url: get_optional("AUTH_ADMIN_TOKEN_URL"), + auth_admin_audience: get_optional("AUTH_ADMIN_AUDIENCE"), + auth_admin_scopes: get_optional("AUTH_ADMIN_SCOPES"), + auth_admin_issuer: get_optional("AUTH_ADMIN_ISSUER"), // Superposition settings superposition_url: get_env("SUPERPOSITION_URL", None)?, @@ -179,7 +211,7 @@ impl AppConfig { ), // Feature flags - enable_google_signin: parse_env("ENABLE_GOOGLE_SIGNIN", false), + enabled_oidc_idps, organisation_creation_disabled: parse_env("ORGANISATION_CREATION_DISABLED", false), // Google Sheets @@ -202,3 +234,13 @@ impl AppConfig { }) } } + +fn parse_csv_env_list(raw: &str) -> Vec { + let mut seen = HashSet::new(); + raw.split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_ascii_lowercase()) + .filter(|value| seen.insert(value.clone())) + .collect() +} diff --git a/airborne_server/src/dashboard/configuration.rs b/airborne_server/src/dashboard/configuration.rs index 038ba788..7affbbe5 100644 --- a/airborne_server/src/dashboard/configuration.rs +++ b/airborne_server/src/dashboard/configuration.rs @@ -28,7 +28,13 @@ pub fn add_routes() -> Scope { #[derive(Serialize, Deserialize)] struct Configuration { google_signin_enabled: bool, + enabled_oidc_idps: Vec, organisation_creation_disabled: bool, + authn_provider: String, + authz_provider: String, + oidc_login_enabled: bool, + password_login_enabled: bool, + registration_enabled: bool, } #[get("")] @@ -37,8 +43,18 @@ async fn get_global_configurations( state: web::Data, ) -> airborne_types::Result> { let config = Configuration { - google_signin_enabled: state.env.enable_google_signin, + google_signin_enabled: state + .env + .enabled_oidc_idps + .iter() + .any(|idp| idp.eq_ignore_ascii_case("google")), + enabled_oidc_idps: state.env.enabled_oidc_idps.clone(), organisation_creation_disabled: state.env.organisation_creation_disabled, + authn_provider: state.authn_provider.kind().as_str().to_string(), + authz_provider: state.authz_provider.kind().as_str().to_string(), + oidc_login_enabled: state.authn_provider.is_oidc_login_enabled(state.get_ref()), + password_login_enabled: state.authn_provider.supports_password_login(), + registration_enabled: state.authn_provider.supports_signup(), }; Ok(Json(config)) diff --git a/airborne_server/src/file.rs b/airborne_server/src/file.rs index 45ccff25..14cae49b 100644 --- a/airborne_server/src/file.rs +++ b/airborne_server/src/file.rs @@ -11,6 +11,7 @@ use actix_web::{ web::{self, Json, Path, Payload, Query, ReqData}, Scope, }; +use airborne_authz_macros::authz; use aws_sdk_s3::primitives::ByteStream; use chrono::Utc; use diesel::prelude::*; @@ -26,7 +27,7 @@ use zip::ZipArchive; use crate::{ file::types::*, - middleware::auth::{validate_user, AuthResponse, ADMIN, READ, WRITE}, + middleware::auth::{require_org_and_app, AuthResponse}, run_blocking, types as airborne_types, types::{ABError, AppState, WithHeaders}, utils::{ @@ -70,6 +71,12 @@ fn db_file_to_response(file: &DbFile) -> FileResponse { } } +#[authz( + resource = "file", + action = "create", + org_roles = ["owner", "admin", "write"], + app_roles = ["admin", "write"] +)] #[post("")] async fn create_file( req: Json, @@ -78,17 +85,10 @@ async fn create_file( ) -> airborne_types::Result> { let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), WRITE) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; let (file_size, file_checksum) = match (&req.size, &req.checksum) { (Some(provided_size), Some(provided_checksum)) => { @@ -202,6 +202,12 @@ async fn create_file( Ok(Json(db_file_to_response(&created_file))) } +#[authz( + resource = "file", + action = "create", + org_roles = ["owner", "admin", "write"], + app_roles = ["admin", "write"] +)] #[post("/bulk")] async fn bulk_create_files( req: Json, @@ -209,17 +215,10 @@ async fn bulk_create_files( state: web::Data, ) -> airborne_types::Result>> { let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), WRITE) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; let pool = state.db_pool.clone(); let request = req.into_inner(); @@ -334,6 +333,12 @@ async fn bulk_create_files( /// Retrieves a file by its Key. /// The Key is expected to be in the format "$file_path@version:$version_number" or "$file_path@tag:$tag". +#[authz( + resource = "file", + action = "read", + org_roles = ["owner", "admin", "write", "read"], + app_roles = ["admin", "write", "read"] +)] #[get("")] async fn get_file( query: Query, @@ -356,17 +361,10 @@ async fn get_file( } let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), READ) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; let pool = state.db_pool.clone(); @@ -413,6 +411,12 @@ fn parse_tags(tags: Option<&str>) -> Vec { .unwrap_or_default() } +#[authz( + resource = "file", + action = "read", + org_roles = ["owner", "admin", "write", "read"], + app_roles = ["admin", "write", "read"] +)] #[get("/list")] async fn list_files( query: Query, @@ -420,17 +424,10 @@ async fn list_files( state: web::Data, ) -> airborne_types::Result> { let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), READ) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; let pool = state.db_pool.clone(); let search_term = query.search.clone(); @@ -516,6 +513,12 @@ pub struct ListFileTagsQuery { pub search: Option, } +#[authz( + resource = "file", + action = "read", + org_roles = ["owner", "admin", "write", "read"], + app_roles = ["admin", "write", "read"] +)] #[get("/tags")] async fn list_file_tags( query: Query, @@ -523,17 +526,10 @@ async fn list_file_tags( state: web::Data, ) -> airborne_types::Result>> { let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), READ) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; let pool = state.db_pool.clone(); let query = query.into_inner(); @@ -611,6 +607,12 @@ async fn list_file_tags( /// Updates a file's tag /// File Key is expected to be in the format "$file_path@version:$version_number" or "$file_path@tag:$tag". +#[authz( + resource = "file", + action = "update", + org_roles = ["owner", "admin", "write"], + app_roles = ["admin", "write"] +)] #[patch("/{file_key}")] async fn update_file( path: Path, @@ -626,17 +628,10 @@ async fn update_file( } let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), WRITE) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; let pool = state.db_pool.clone(); let request = req.into_inner(); @@ -681,6 +676,12 @@ async fn update_file( Ok(Json(db_file_to_response(&file))) } +#[authz( + resource = "file", + action = "upload", + org_roles = ["owner", "admin", "write"], + app_roles = ["admin", "write"] +)] #[post("/upload")] async fn upload_file( req: actix_web::HttpRequest, @@ -691,17 +692,10 @@ async fn upload_file( ) -> airborne_types::Result> { let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), WRITE) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; let file_path_str = query.file_path.clone(); let tag_str = query.tag.clone(); @@ -949,6 +943,12 @@ async fn upload_file( } } +#[authz( + resource = "file", + action = "upload", + org_roles = ["owner", "admin", "write"], + app_roles = ["admin", "write"] +)] #[post("/bulk_upload")] async fn upload_bulk_files( MultipartForm(req): MultipartForm, @@ -956,17 +956,10 @@ async fn upload_bulk_files( state: web::Data, ) -> airborne_types::Result> { let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), WRITE) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; let tmp_path = std::env::temp_dir().join(format!("bulk-{}.zip", Uuid::new_v4())); tokio::fs::copy(req.file.file.path(), &tmp_path) diff --git a/airborne_server/src/file/groups.rs b/airborne_server/src/file/groups.rs index c6338144..c3ac3396 100644 --- a/airborne_server/src/file/groups.rs +++ b/airborne_server/src/file/groups.rs @@ -4,13 +4,14 @@ use actix_web::{ get, web::{self, Json, Query, ReqData}, }; +use airborne_authz_macros::authz; use diesel::prelude::*; use crate::{ file::groups::types::*, - middleware::auth::{validate_user, AuthResponse, ADMIN, READ}, + middleware::auth::{require_org_and_app, AuthResponse}, run_blocking, types as airborne_types, - types::{ABError, AppState}, + types::AppState, utils::db::{models::FileEntry as DbFile, schema::hyperotaserver::files::dsl::*}, }; @@ -38,6 +39,12 @@ fn escape_like_pattern(input: &str) -> String { .replace('_', "\\_") } +#[authz( + resource = "file_group", + action = "read", + org_roles = ["owner", "admin", "write", "read"], + app_roles = ["admin", "write", "read"] +)] #[get("")] async fn list_file_groups( query: Query, @@ -45,17 +52,10 @@ async fn list_file_groups( state: web::Data, ) -> airborne_types::Result> { let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), READ) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; let pool = state.db_pool.clone(); let query = query.into_inner(); diff --git a/airborne_server/src/main.rs b/airborne_server/src/main.rs index e3833b0c..348a51d9 100644 --- a/airborne_server/src/main.rs +++ b/airborne_server/src/main.rs @@ -13,6 +13,9 @@ // limitations under the License. #![deny(unused_crate_dependencies)] +use airborne_authz_macros as _; + +mod authz; mod build; mod config; mod dashboard; @@ -21,7 +24,9 @@ mod file; mod middleware; mod organisation; mod package; +mod provider; mod release; +mod service_account; mod token; mod types; mod user; @@ -44,11 +49,12 @@ use log::info; use serde_json::json; use std::{ hash::{DefaultHasher, Hash, Hasher}, + str::FromStr, sync::Arc, }; use superposition_sdk::config::Config as SrsConfig; use tracing_actix_web::TracingLogger; -use utils::{db, transaction_manager::start_cleanup_job}; +use utils::db; use crate::{ dashboard::configuration, @@ -56,6 +62,13 @@ use crate::{ auth::Auth, request::{req_id_header_mw, WithRequestId}, }, + provider::{ + authn::build_authn_provider, + authz::{ + build_authz_provider, + migration::{import_keycloak_authz_to_casbin, parse_keycloak_admin_issuer}, + }, + }, utils::{ interceptor::CookieIntercept, migrations::{ @@ -72,12 +85,66 @@ pub fn calculate_bucket_index(identifier: &str, group_id: &i64) -> usize { (hasher.finish() % 100) as usize } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum StartupCommand { + Serve, + ImportKeycloakAuthz { apply: bool }, +} + +fn parse_startup_command() -> Result { + let mut args = std::env::args().skip(1); + let Some(command) = args.next() else { + return Ok(StartupCommand::Serve); + }; + + if command != "authz-import-keycloak" { + return Ok(StartupCommand::Serve); + } + + let mut apply = false; + let mut dry_run = false; + for arg in args { + match arg.as_str() { + "--apply" => apply = true, + "--dry-run" => dry_run = true, + unknown => { + return Err(format!( + "Unknown argument '{}'. Supported flags: --dry-run, --apply", + unknown + )); + } + } + } + + if apply && dry_run { + return Err("Use either --apply or --dry-run, not both".to_string()); + } + + Ok(StartupCommand::ImportKeycloakAuthz { apply }) +} + +fn trim_trailing_slash(value: &str) -> String { + value.trim_end_matches('/').to_string() +} + +fn normalize_external_base_url(value: &str) -> String { + let trimmed = trim_trailing_slash(value); + if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + trimmed + } else { + format!("http://{trimmed}") + } +} + #[actix_web::main] async fn main() -> std::io::Result<()> { let log_format = std::env::var("LOG_FORMAT").unwrap_or_default(); utils::init_tracing(log_format); dotenv().ok(); + let startup_command = parse_startup_command().expect( + "Invalid startup command. Use 'authz-import-keycloak [--dry-run|--apply]' or run without arguments", + ); let shared_config = aws_config::from_env().load().await; let aws_kms_client = aws_sdk_kms::Client::new(&shared_config); @@ -86,20 +153,26 @@ async fn main() -> std::io::Result<()> { .await .expect("Failed to build AppConfig"); - let superposition_migration_strategy = - SuperpositionMigrationStrategy::from(app_config.superposition_migration_strategy.clone()); - let migrations_to_run_on_boot: Vec = app_config .migrations_to_run_on_boot .split(',') - .map(|s| s.trim().into()) + .map(|s| s.trim().to_ascii_lowercase()) + .filter(|s| !s.is_empty()) .collect(); + let superposition_migration_strategy = + SuperpositionMigrationStrategy::from(app_config.superposition_migration_strategy.clone()); + let force_path_style = app_config.aws_endpoint_url.is_some(); let aws_cloudfront_client = aws_sdk_cloudfront::Client::new(&shared_config); - if migrations_to_run_on_boot.contains(&"db".to_string()) { + let should_run_db_migrations = migrations_to_run_on_boot.iter().any(|m| m == "db"); + let should_run_keycloak_to_casbin = migrations_to_run_on_boot + .iter() + .any(|m| m == "keycloaktocasbin"); + + if should_run_db_migrations || should_run_keycloak_to_casbin { info!("Running pending database migrations"); let mut conn = db::establish_connection(&app_config).await; conn.run_pending_migrations(MIGRATIONS) @@ -142,26 +215,110 @@ async fn main() -> std::io::Result<()> { info!("Creating db pool"); let pool = db::establish_pool(&app_config).await; - let secret = app_config.keycloak_secret.clone(); + if let StartupCommand::ImportKeycloakAuthz { apply } = startup_command { + info!("Running Keycloak -> Casbin import (apply={})", apply); + let mut conn = db::establish_connection(&app_config).await; + conn.run_pending_migrations(MIGRATIONS) + .expect("Failed to run pending migrations before import"); + import_keycloak_authz_to_casbin(&app_config, pool.clone(), apply) + .await + .expect("Failed to complete Keycloak -> Casbin import"); + return Ok(()); + } + + if should_run_keycloak_to_casbin { + info!("Running Keycloak -> Casbin import from MIGRATIONS_TO_RUN_ON_BOOT"); + import_keycloak_authz_to_casbin(&app_config, pool.clone(), true) + .await + .expect("Failed to complete Keycloak -> Casbin import"); + } + let superposition_token = app_config.superposition_token.clone().unwrap_or_default(); let cac_url = app_config.superposition_url.clone(); let superposition_org_id_env = app_config.superposition_org_id.clone(); + let authn_provider_kind = types::AuthnProviderKind::from_str(&app_config.authn_provider) + .expect("AUTHN_PROVIDER must be one of: keycloak, oidc, okta, auth0"); + let authz_provider_kind = types::AuthzProviderKind::from_str(&app_config.authz_provider) + .expect("AUTHZ_PROVIDER must be one of: casbin"); + let issuer = app_config + .oidc_issuer_url + .clone() + .expect("OIDC_ISSUER_URL must be set"); + let external_issuer = app_config + .oidc_external_issuer_url + .clone() + .unwrap_or_else(|| issuer.clone()); + let authn_issuer_url = trim_trailing_slash(&issuer); + let authn_external_issuer_url = normalize_external_base_url(&external_issuer); + let authn_client_id = app_config + .oidc_client_id + .clone() + .expect("OIDC_CLIENT_ID must be set"); + let authn_client_secret = app_config + .oidc_client_secret + .clone() + .expect("OIDC_CLIENT_SECRET must be set"); + let authn_clock_skew_secs = app_config.oidc_clock_skew_secs; + let authz_bootstrap_super_admins = app_config + .authz_bootstrap_super_admins + .clone() + .unwrap_or_default() + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_ascii_lowercase()) + .collect::>(); + let authz_casbin_auto_load_secs = app_config.authz_casbin_auto_load_secs; + let auth_admin_client_id = app_config.auth_admin_client_id.clone().unwrap_or_default(); + let auth_admin_client_secret = app_config + .auth_admin_client_secret + .clone() + .unwrap_or_default(); + let auth_admin_token_url = app_config.auth_admin_token_url.clone().unwrap_or_default(); + let auth_admin_audience = app_config.auth_admin_audience.clone(); + let auth_admin_scopes = app_config.auth_admin_scopes.clone(); + let (keycloak_url, realm) = app_config + .auth_admin_issuer + .as_deref() + .map(parse_keycloak_admin_issuer) + .transpose() + .expect("Invalid AUTH_ADMIN_ISSUER format") + .unwrap_or_else(|| ("".to_string(), "".to_string())); + + if authn_provider_kind == types::AuthnProviderKind::Keycloak { + if auth_admin_client_id.trim().is_empty() { + panic!("AUTH_ADMIN_CLIENT_ID must be set when AUTHN_PROVIDER=keycloak"); + } + if auth_admin_client_secret.trim().is_empty() { + panic!("AUTH_ADMIN_CLIENT_SECRET must be set when AUTHN_PROVIDER=keycloak"); + } + if auth_admin_token_url.trim().is_empty() { + panic!("AUTH_ADMIN_TOKEN_URL must be set when AUTHN_PROVIDER=keycloak"); + } + if keycloak_url.trim().is_empty() || realm.trim().is_empty() { + panic!("AUTH_ADMIN_ISSUER must be set when AUTHN_PROVIDER=keycloak"); + } + } + let env = types::Environment { public_url: app_config.public_endpoint.clone(), - keycloak_url: app_config.keycloak_url.clone(), - keycloak_external_url: app_config.keycloak_external_url.clone(), - keycloak_public_key: format!( - "-----BEGIN PUBLIC KEY-----\n{}\n-----END PUBLIC KEY-----", - app_config.keycloak_public_key - ), - client_id: app_config.keycloak_client_id.clone(), - secret: secret.clone(), - realm: app_config.keycloak_realm.clone(), + authn_issuer_url, + authn_external_issuer_url, + authn_client_id: authn_client_id.clone(), + authn_client_secret: authn_client_secret.clone(), + authn_clock_skew_secs, + auth_admin_client_id, + auth_admin_client_secret, + auth_admin_token_url, + auth_admin_audience, + auth_admin_scopes, + keycloak_url, + realm, bucket_name: app_config.aws_bucket.clone(), superposition_org_id: app_config.superposition_org_id.clone(), - enable_google_signin: app_config.enable_google_signin, + enabled_oidc_idps: app_config.enabled_oidc_idps.clone(), organisation_creation_disabled: app_config.organisation_creation_disabled, google_spreadsheet_id: spreadsheet_id.clone().unwrap_or_default(), cloudfront_distribution_id: app_config.cloudfront_distribution_id.clone(), @@ -234,21 +391,38 @@ async fn main() -> std::io::Result<()> { ) }; + let authz_provider = build_authz_provider( + authz_provider_kind, + authz_bootstrap_super_admins.clone(), + pool.clone(), + authz_casbin_auto_load_secs, + ) + .await + .expect("Failed to initialize AuthZ provider"); + let app_state = Arc::new(types::AppState { env: env.clone(), + authn_provider: build_authn_provider(authn_provider_kind), + authz_provider, db_pool: pool, s3_client: aws_s3_client, cf_client: aws_cloudfront_client, superposition_client, sheets_hub: hub, }); + app_state + .authz_provider + .bootstrap(app_state.as_ref()) + .await + .expect("Failed to bootstrap AuthZ provider"); // Start the background cleanup job for transaction reconciliation let app_state_data = web::Data::from(app_state.clone()); - let _cleanup_handle = start_cleanup_job(app_state_data.clone()); - info!("Started transaction cleanup background job"); - if migrations_to_run_on_boot.contains(&"superposition".to_string()) { + if migrations_to_run_on_boot + .iter() + .any(|m| m == "superposition") + { let superposition_migration = migrate_superposition(&app_state_data, superposition_migration_strategy).await; if superposition_migration.is_err() { @@ -291,6 +465,7 @@ async fn main() -> std::io::Result<()> { .service( web::scope("/dashboard/configuration").service(configuration::add_routes()), ) + .service(web::scope("/authz").wrap(Auth).service(authz::add_routes())) .service( web::scope("/organisations") .wrap(Auth) @@ -303,6 +478,11 @@ async fn main() -> std::io::Result<()> { ) .service(user::add_routes("users")) .service(token::add_scopes("token")) + .service( + web::scope("/service-accounts") + .wrap(Auth) + .service(service_account::add_routes()), + ) .service(web::scope("/file").wrap(Auth).service(file::add_routes())) .service( web::scope("/packages") diff --git a/airborne_server/src/middleware/auth.rs b/airborne_server/src/middleware/auth.rs index bebdded6..5c349e1f 100644 --- a/airborne_server/src/middleware/auth.rs +++ b/airborne_server/src/middleware/auth.rs @@ -17,19 +17,13 @@ use std::{ rc::Rc, }; +use crate::types::AppState; use actix_web::{ dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform}, web::Data, Error, HttpMessage, }; use futures::future::LocalBoxFuture; -use keycloak::{KeycloakAdmin, KeycloakAdminToken}; -use reqwest::Client; - -use crate::{ - types::AppState, - utils::keycloak::{decode_jwt_token, get_token}, -}; use crate::types::{ABError, Result as ABResult}; @@ -73,8 +67,13 @@ pub struct AccessLevel { #[derive(Clone, Debug)] pub struct AuthResponse { - pub sub: String, - pub admin_token: KeycloakAdminToken, // This is holding token and not admin since admin deos not have clone + pub sub: String, // Canonical AuthZ subject (email) + #[allow(dead_code)] + pub authn_sub: String, + #[allow(dead_code)] + pub authn_iss: Option, + #[allow(dead_code)] + pub authn_email: Option, pub organisation: Option, pub application: Option, pub is_super_admin: bool, @@ -90,30 +89,20 @@ pub const OWNER: Access = Access { access: 4 }; pub const ADMIN: Access = Access { access: 3 }; pub const WRITE: Access = Access { access: 2 }; pub const READ: Access = Access { access: 1 }; -pub const ROLES: [&str; 4] = ["owner", "admin", "write", "read"]; -pub fn validate_user(access_level: Option, access: Access) -> ABResult { - if let Some(access_level) = access_level { - if access_level.level >= access.access { - Ok(access_level.name) - } else { - Err(ABError::Forbidden("Access Level too low".to_string())) - } - } else { - Err(ABError::BadRequest("Missing header".to_string())) - } +pub fn require_scope_name(access_level: Option, scope: &str) -> ABResult { + access_level + .map(|value| value.name) + .ok_or_else(|| ABError::Forbidden(format!("No {} access", scope))) } -fn get_access_level(user_groups: &[String], path: &str) -> Option { - static ACCESS_LIST: [&str; 4] = ["owner", "admin", "write", "read"]; - ACCESS_LIST.iter().enumerate().find_map(|(i, role)| { - let full_path = format!("/{}/{}", path, role); // match format of a - if user_groups.contains(&full_path) { - Some(ACCESS_LIST.len() - i) - } else { - None - } - }) +pub fn require_org_and_app( + organisation: Option, + application: Option, +) -> ABResult<(String, String)> { + let organisation = require_scope_name(organisation, "organisation")?; + let application = require_scope_name(application, "application")?; + Ok((organisation, application)) } impl Service for AuthMiddleware @@ -132,8 +121,8 @@ where let service = self.service.clone(); Box::pin(async move { - let env = match req.app_data::>() { - Some(val) => val.env.clone(), + let app_state = match req.app_data::>() { + Some(val) => val.clone(), None => { log::error!("app state not set"); Err(ABError::InternalServerError("Env not found".to_string()))? @@ -151,111 +140,47 @@ where .and_then(|auth_str| auth_str.strip_prefix("Bearer ")); let org = org_header.and_then(|org_header| org_header.to_str().ok()); let app = app_header.and_then(|app_header| app_header.to_str().ok()); - let token = get_token(env.clone(), Client::new()).await; - match token { - Ok(token) => match auth { - Some(auth) => { - let token_data = decode_jwt_token( - auth, - &env.keycloak_public_key.clone(), - &env.client_id.clone(), + match auth { + Some(access_token) => { + let token_data = app_state + .authn_provider + .verify_access_token(app_state.get_ref(), access_token) + .await?; + + let authz_subject = app_state + .authz_provider + .subject_from_claims(&token_data.claims)?; + let access_context = app_state + .authz_provider + .access_for_request(app_state.get_ref(), &authz_subject, org, app) + .await?; + + if org.is_some() && access_context.organisation.is_none() { + return Err( + ABError::Forbidden("No Access to Organisation".to_string()).into() ); - match token_data { - Ok(token_data) => { - let mut organisation = None; - let mut application = None; - - // Fetch user groups from Keycloak - let client = reqwest::Client::new(); - let admin = KeycloakAdmin::new( - &env.keycloak_url.clone(), - token.clone(), - client, - ); - let user_groups: Vec = admin - .realm_users_with_user_id_groups_get( - &env.realm.clone(), - &token_data.claims.sub, - None, - None, - None, - None, - ) - .await - .map_err(|e| ABError::Unauthorized(e.to_string()))?; - - let user_groups: Vec = user_groups - .iter() - .filter_map(|group| group.path.clone()) - .collect(); - - // Check super admin status - let is_super_admin = - user_groups.contains(&"/super_admin".to_string()); - - // Check organization and application access if org header is present - if let Some(org) = org { - if let Some(app) = app { - let access = get_access_level( - &user_groups, - &format!("{}/{}", org, app), - ); - match access { - Some(level) => { - application = Some(AccessLevel { - name: app.to_string(), - level: level as u8, - }); - } - None => { - return Err(ABError::Forbidden( - "No Access to Application".to_string(), - ) - .into()) - } - }; - } - let access = get_access_level(&user_groups, org); - match access { - Some(level) => { - organisation = Some(AccessLevel { - name: org.to_string(), - level: level as u8, - }); - } - None => { - return Err(ABError::Forbidden( - "No Access to Organisation".to_string(), - ) - .into()); - } - }; - } - - req.extensions_mut().insert(AuthResponse { - sub: token_data.claims.sub, - admin_token: token, - organisation, - application, - is_super_admin, - username: token_data - .claims - .preferred_username - .clone() - .ok_or_else(|| { - ABError::Unauthorized( - "No username in token".to_string(), - ) - })?, - }); - service.call(req).await - } - Err(e) => Err(e.into()), - } } - None => Err(ABError::Unauthorized("No AdminToken".to_string()).into()), - }, - Err(e) => Err(ABError::Unauthorized(format!("{:?}", e)).into()), + if app.is_some() && access_context.application.is_none() { + return Err( + ABError::Forbidden("No Access to Application".to_string()).into() + ); + } + + req.extensions_mut().insert(AuthResponse { + sub: authz_subject, + authn_sub: token_data.claims.sub.clone(), + authn_iss: token_data.claims.iss.clone(), + authn_email: token_data.claims.email.clone(), + organisation: access_context.organisation, + application: access_context.application, + is_super_admin: access_context.is_super_admin, + username: app_state + .authz_provider + .display_name_from_claims(&token_data.claims), + }); + service.call(req).await + } + None => Err(ABError::Unauthorized("No Authorization token".to_string()).into()), } }) } @@ -266,22 +191,3 @@ impl AccessLevel { self.level >= ADMIN.access } } - -pub async fn validate_required_access( - auth: &AuthResponse, - required_level: u8, - operation: &str, -) -> ABResult<()> { - if let Some(access) = &auth.organisation { - if access.level >= required_level { - Ok(()) - } else { - Err(ABError::Forbidden(format!( - "Insufficient permissions for {}", - operation - ))) - } - } else { - Err(ABError::Forbidden("No organisation access".to_string())) - } -} diff --git a/airborne_server/src/organisation.rs b/airborne_server/src/organisation.rs index 1d234fb0..a906bf0c 100644 --- a/airborne_server/src/organisation.rs +++ b/airborne_server/src/organisation.rs @@ -22,14 +22,10 @@ use actix_web::{ HttpMessage, HttpRequest, Scope, }; use google_sheets4::api::ValueRange; -use keycloak::KeycloakAdmin; use serde::{Deserialize, Serialize}; use serde_json::json; -use std::collections::HashMap; pub mod application; -pub mod transaction; -pub mod types; pub mod user; // Constants @@ -81,7 +77,7 @@ pub struct OrganisationListResponse { #[post("/request")] async fn request_organisation( - req: HttpRequest, + _req: HttpRequest, body: Json, state: web::Data, ) -> airborne_types::Result> { @@ -94,33 +90,12 @@ async fn request_organisation( // Validate organization name validate_organisation_name(&organisation_name)?; - // Get Keycloak Admin Token - let auth_response = req - .extensions() - .get::() - .cloned() - .ok_or(ABError::Unauthorized("Token Parse Failed".to_string()))?; - let admin_token = auth_response.admin_token.clone(); - let client = reqwest::Client::new(); - let admin = KeycloakAdmin::new(&state.env.keycloak_url.clone(), admin_token, client); - let realm = state.env.realm.clone(); - // Check if organization already exists - let groups = admin - .realm_groups_get( - &realm, - None, - Some(true), - None, - Some(2), - Some(false), - None, - Some(organisation_name.clone()), - ) - .await - .map_err(|e| ABError::Unauthorized(format!("Failed to check existing groups: {}", e)))?; - - if !groups.is_empty() { + if state + .authz_provider + .organisation_exists(state.get_ref(), &organisation_name) + .await? + { return Err(ABError::BadRequest( "Organisation name is taken".to_string(), )); @@ -176,17 +151,12 @@ async fn create_organisation( // Validate organization name validate_organisation_name(&organisation)?; - // Get Keycloak Admin Token let auth_response = req .extensions() .get::() .cloned() .ok_or(ABError::Unauthorized("Token Parse Failed".to_string()))?; - let admin_token = auth_response.admin_token.clone(); let sub = &auth_response.sub; - let client = reqwest::Client::new(); - let admin = KeycloakAdmin::new(&state.env.keycloak_url.clone(), admin_token, client); - let realm = state.env.realm.clone(); if state.env.organisation_creation_disabled && !auth_response.is_super_admin { return Err(ABError::BadRequest( @@ -195,42 +165,31 @@ async fn create_organisation( } // Check if organization already exists - let groups = admin - .realm_groups_get( - &realm, - None, - Some(true), - None, - Some(2), - Some(false), - None, - Some(organisation.clone()), - ) - .await - .map_err(|e| { - ABError::InternalServerError(format!("Failed to check existing groups: {}", e)) - })?; - - if !groups.is_empty() { + if state + .authz_provider + .organisation_exists(state.get_ref(), &organisation) + .await? + { return Err(ABError::BadRequest( "Organisation name is taken".to_string(), )); } - // Create the organization using the transaction manager - let org = transaction::create_organisation_with_transaction( - &organisation, - &admin, - &realm, - sub, - &state, - ) - .await - .map_err(|e| { - ABError::InternalServerError(format!("Error occurred while creating org: {:?}", e)) - })?; - - Ok(Json(org)) + state + .authz_provider + .create_organisation(state.get_ref(), &organisation, sub) + .await?; + + Ok(Json(Organisation { + name: organisation, + applications: vec![], + access: vec![ + "owner".to_string(), + "admin".to_string(), + "write".to_string(), + "read".to_string(), + ], + })) } #[delete("/{org_name}")] @@ -244,35 +203,18 @@ async fn delete_organisation( // Validate organization name validate_organisation_name(&organisation)?; - // Get Keycloak Admin Token let auth_response = req .extensions() .get::() .cloned() .ok_or(ABError::Unauthorized("Token Parse Failed".to_string()))?; - let admin_token = auth_response.admin_token.clone(); - let client = reqwest::Client::new(); - let admin = KeycloakAdmin::new(&state.env.keycloak_url.clone(), admin_token, client); - let realm = state.env.realm.clone(); // Check if organization exists - let groups = admin - .realm_groups_get( - &realm, - None, - Some(true), - None, - Some(2), - Some(false), - None, - Some(organisation.clone()), - ) - .await - .map_err(|e| { - ABError::InternalServerError(format!("Failed to check existing groups: {}", e)) - })?; - - if groups.is_empty() { + if !state + .authz_provider + .organisation_exists(state.get_ref(), &organisation) + .await? + { return Err(ABError::BadRequest( "Organisation does not exist".to_string(), )); @@ -284,12 +226,10 @@ async fn delete_organisation( .as_ref() .is_some_and(|org| org.name == organisation && org.is_admin_or_higher()) { - // Delete the organization using the transaction manager - transaction::delete_organisation_with_transaction(&organisation, &admin, &realm, &state) - .await - .map_err(|_| { - ABError::InternalServerError("Could not delete organisation".to_string()) - })?; + state + .authz_provider + .delete_organisation(state.get_ref(), &organisation) + .await?; Ok(Json( json!({"Success" : "Organisation deleted successfully"}), @@ -306,106 +246,35 @@ async fn list_organisations( req: HttpRequest, state: web::Data, ) -> airborne_types::Result> { - // Get Keycloak Admin Token let auth_response = req .extensions() .get::() .cloned() .ok_or(ABError::Unauthorized("Token Parse Failed".to_string()))?; - let admin_token = auth_response.admin_token.clone(); - let sub = &auth_response.sub; - let client = reqwest::Client::new(); - let admin = KeycloakAdmin::new(&state.env.keycloak_url.clone(), admin_token, client); - let realm = state.env.realm.clone(); - - // Get user's groups - let groups = admin - .realm_users_with_user_id_groups_get(&realm, sub, None, None, None, None) - .await - .map_err(|e| ABError::InternalServerError(format!("Failed to fetch user groups: {}", e)))?; - - // Extract group paths - let group_paths: Vec = groups.iter().filter_map(|g| g.path.clone()).collect(); - - // Parse groups into organizations - let organizations = parse_user_organizations(group_paths); - - Ok(Json(OrganisationListResponse { - organisations: organizations, - })) -} - -// Helper function to parse Keycloak groups into organizations -fn parse_user_organizations(groups: Vec) -> Vec { - let mut organisations: HashMap = HashMap::new(); - - for group in groups { - let path = group.trim_matches('/'); // Remove leading/trailing slashes - let parts: Vec<&str> = path.split('/').collect(); - - if path == "super_admin" { - continue; - } - - if parts.is_empty() { - continue; - } - - let access = parts.last().unwrap_or(&"").to_string(); - - // Skip if no organization name found - if parts.is_empty() { - continue; - } - - let organisation_name = parts[0].to_string(); - let application_name = if parts.len() == 3 { - Some(parts[1].to_string()) - } else { - None - }; - - if let Some(app_name) = application_name { - // Handle application-level access - let organisation = - organisations - .entry(organisation_name.clone()) - .or_insert(Organisation { - name: organisation_name.clone(), - applications: vec![], - access: vec![], - }); - - let app = organisation + let summary = state + .authz_provider + .get_user_access_summary(state.get_ref(), &auth_response.sub) + .await?; + let mut organisations = summary + .organisations + .into_iter() + .map(|org| Organisation { + name: org.name, + applications: org .applications - .iter_mut() - .find(|app| app.application == app_name); - - if let Some(app) = app { - app.access.push(access); - } else { - organisation.applications.push(Application { - application: app_name, - organisation: organisation_name.clone(), - access: vec![access], - }); - } - } else { - // Handle organisation-level access - let organisation = - organisations - .entry(organisation_name.clone()) - .or_insert(Organisation { - name: organisation_name.clone(), - applications: vec![], - access: vec![], - }); - - organisation.access.push(access); - } - } - - organisations.into_values().collect() + .into_iter() + .map(|app| Application { + application: app.application, + organisation: app.organisation, + access: app.access, + }) + .collect(), + access: org.access, + }) + .collect::>(); + organisations.sort_by(|left, right| left.name.cmp(&right.name)); + + Ok(Json(OrganisationListResponse { organisations })) } /// Validate organization name for security and usability diff --git a/airborne_server/src/organisation/application.rs b/airborne_server/src/organisation/application.rs index bef08e96..8d23b520 100644 --- a/airborne_server/src/organisation/application.rs +++ b/airborne_server/src/organisation/application.rs @@ -16,12 +16,11 @@ use std::collections::HashMap; use actix_web::web::{Json, ReqData}; use actix_web::Scope; +use airborne_authz_macros::authz; use actix_web::{post, web}; use aws_smithy_types::Document; use diesel::RunQueryDsl; -use keycloak::types::GroupRepresentation; -use keycloak::KeycloakAdmin; use log::info; use serde::{Deserialize, Serialize}; use superposition_sdk::operation::create_default_config::CreateDefaultConfigOutput; @@ -29,7 +28,7 @@ use superposition_sdk::types::WorkspaceStatus; use superposition_sdk::Client; use crate::{ - middleware::auth::{validate_user, AuthResponse, ADMIN}, + middleware::auth::{require_scope_name, AuthResponse}, types::{self as airborne_types, ABError, AppState}, utils::{ db::{ @@ -37,9 +36,7 @@ use crate::{ schema::hyperotaserver::workspace_names, }, document::schema_doc_to_hashmap, - keycloak::get_token, migrations::{migrate_superposition_workspace, SuperpositionMigrationStrategy}, - transaction_manager::TransactionManager, }, }; @@ -138,6 +135,14 @@ where }) } +#[authz( + resource = "application", + action = "create", + org_roles = ["owner", "admin"], + app_roles = [], + allow_org = true, + allow_app = false +)] #[post("/create")] async fn add_application( body: Json, @@ -148,304 +153,73 @@ async fn add_application( let body = body.into_inner(); let application = body.application; - // Check if the user token is still valid let auth_response = auth_response.into_inner(); let sub = &auth_response.sub; + let organisation = require_scope_name(auth_response.organisation, "organisation")?; + info!( + "Validated org context '{}' while creating app '{}'", + organisation, application + ); + + state + .authz_provider + .create_application(state.get_ref(), &organisation, &application, sub) + .await?; - let organisation = auth_response.organisation; - - info!("Validating organisation: {:?}", organisation); - let organisation = validate_user(organisation, ADMIN).map_err(|e| { - info!("Error validating organisation: {:?}", e); - ABError::Forbidden(format!( - "User does not have ADMIN access to organisation: {}", - e - )) - })?; - info!("Organisation validated successfully."); - - // Create a transaction manager to track resources - let transaction = TransactionManager::new(&application, "application_create"); - - // Get DB connection 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( - create_fn: impl futures::Future>, - key: &str, - transaction: &TransactionManager, - admin: &KeycloakAdmin, - realm: &str, - state: &web::Data, - ) -> 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)))?; + + Ok(Json(Application { + application, + organisation, + access: vec!["read".to_string(), "write".to_string(), "admin".to_string()], + })) } diff --git a/airborne_server/src/organisation/application/config.rs b/airborne_server/src/organisation/application/config.rs index a3f33824..694b66ee 100644 --- a/airborne_server/src/organisation/application/config.rs +++ b/airborne_server/src/organisation/application/config.rs @@ -18,6 +18,7 @@ use actix_web::{ web::{self, Json, ReqData}, Scope, }; +use airborne_authz_macros::authz; use diesel::prelude::*; use diesel::ExpressionMethods; use diesel::QueryDsl; @@ -25,7 +26,7 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use crate::{ - middleware::auth::{validate_user, AuthResponse, WRITE}, + middleware::auth::{require_org_and_app, AuthResponse}, run_blocking, types as airborne_types, types::{ABError, AppState}, utils::db::{ @@ -67,6 +68,12 @@ struct Response { config_version: String, } +#[authz( + resource = "config", + action = "create", + org_roles = ["owner", "admin", "write"], + app_roles = ["admin", "write"] +)] #[post("/create_json_v1")] async fn create_config_json_v1( req: Json, @@ -74,10 +81,8 @@ async fn create_config_json_v1( state: web::Data, ) -> airborne_types::Result> { let auth_response = auth_response.into_inner(); - let organisation = validate_user(auth_response.organisation, WRITE) - .map_err(|_| ABError::Forbidden("No access to org".to_string()))?; - let application = validate_user(auth_response.application, WRITE) - .map_err(|_| ABError::Forbidden("No access to application".to_string()))?; + let (organisation, application) = + require_org_and_app(auth_response.organisation, auth_response.application)?; let pool = state.db_pool.clone(); let request = req.into_inner(); @@ -142,6 +147,12 @@ async fn create_config_json_v1( })) } +#[authz( + resource = "config", + action = "create", + org_roles = ["owner", "admin", "write"], + app_roles = ["admin", "write"] +)] #[post("/create_json_v1/multipart")] async fn create_config_json_v1_multipart( MultipartForm(form): MultipartForm, @@ -149,10 +160,8 @@ async fn create_config_json_v1_multipart( state: web::Data, ) -> airborne_types::Result> { let auth_response = auth_response.into_inner(); - let organisation = validate_user(auth_response.organisation, WRITE) - .map_err(|_| ABError::Forbidden("No access to org".to_string()))?; - let application = validate_user(auth_response.application, WRITE) - .map_err(|_| ABError::Forbidden("No access to application".to_string()))?; + let (organisation, application) = + require_org_and_app(auth_response.organisation, auth_response.application)?; // Parse the JSON request let req: ConfigJsonV1Request = serde_json::from_str(&form.json.into_inner()) diff --git a/airborne_server/src/organisation/application/dimension.rs b/airborne_server/src/organisation/application/dimension.rs index 3dfa6605..af81b191 100644 --- a/airborne_server/src/organisation/application/dimension.rs +++ b/airborne_server/src/organisation/application/dimension.rs @@ -3,10 +3,11 @@ use actix_web::{ web::{self, Json, Path, Query, ReqData}, Scope, }; +use airborne_authz_macros::authz; use serde::Serialize; use crate::{ - middleware::auth::{validate_user, AuthResponse, ADMIN, READ, WRITE}, + middleware::auth::{require_org_and_app, AuthResponse}, organisation::application::dimension::cohort::types::CohortDimensionSchema, run_blocking, types as airborne_types, types::{ABError, AppState}, @@ -47,6 +48,12 @@ struct CreateDimensionResponse { change_reason: String, } +#[authz( + resource = "dimension", + action = "create", + org_roles = ["owner", "admin", "write"], + app_roles = ["admin", "write"] +)] #[post("/create")] async fn create_dimension_api( req: Json, @@ -54,17 +61,10 @@ async fn create_dimension_api( state: web::Data, ) -> airborne_types::Result> { let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), WRITE) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; // Get workspace name for this application let workspace_name = crate::utils::workspace::get_workspace_name_for_application( @@ -175,6 +175,12 @@ async fn create_dimension_api( } } +#[authz( + resource = "dimension", + action = "read", + org_roles = ["owner", "admin", "write", "read"], + app_roles = ["admin", "write", "read"] +)] #[get("/list")] async fn list_dimensions_api( auth_response: ReqData, @@ -182,17 +188,10 @@ async fn list_dimensions_api( state: web::Data, ) -> airborne_types::Result> { let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), READ) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; // Get workspace name for this application let workspace_name = crate::utils::workspace::get_workspace_name_for_application( @@ -253,6 +252,12 @@ async fn list_dimensions_api( })) } +#[authz( + resource = "dimension", + action = "update", + org_roles = ["owner", "admin", "write"], + app_roles = ["admin", "write"] +)] #[put("/{dimension_name}")] async fn update_dimension_api( path: Path, @@ -261,17 +266,10 @@ async fn update_dimension_api( state: web::Data, ) -> airborne_types::Result> { let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), WRITE) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; // Get workspace name for this application let workspace_name = crate::utils::workspace::get_workspace_name_for_application( @@ -328,6 +326,12 @@ async fn update_dimension_api( })) } +#[authz( + resource = "dimension", + action = "delete", + org_roles = ["owner", "admin", "write"], + app_roles = ["admin", "write"] +)] #[delete("/{dimension_name}")] async fn delete_dimension_api( path: Path, @@ -335,17 +339,10 @@ async fn delete_dimension_api( state: web::Data, ) -> airborne_types::Result> { let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), WRITE) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; // Get workspace name for this application let workspace_name = crate::utils::workspace::get_workspace_name_for_application( @@ -369,6 +366,12 @@ async fn delete_dimension_api( Ok(Json(())) } +#[authz( + resource = "release_view", + action = "create", + org_roles = ["owner", "admin", "write"], + app_roles = ["admin", "write"] +)] #[post("/release-view")] async fn create_release_view_api( req: Json, @@ -376,17 +379,10 @@ async fn create_release_view_api( state: web::Data, ) -> airborne_types::Result> { let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), WRITE) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; let workspace_name = crate::utils::workspace::get_workspace_name_for_application( state.db_pool.clone(), @@ -470,6 +466,12 @@ async fn create_release_view_api( })) } +#[authz( + resource = "release_view", + action = "read", + org_roles = ["owner", "admin", "write", "read"], + app_roles = ["admin", "write", "read"] +)] #[get("/release-view/list")] async fn list_release_views_api( auth_response: ReqData, @@ -477,17 +479,10 @@ async fn list_release_views_api( state: web::Data, ) -> airborne_types::Result> { let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), READ) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; let page = query.page.unwrap_or(1).max(1); let count = query.count.unwrap_or(20); @@ -531,6 +526,12 @@ async fn list_release_views_api( })) } +#[authz( + resource = "release_view", + action = "read", + org_roles = ["owner", "admin", "write", "read"], + app_roles = ["admin", "write", "read"] +)] #[get("/release-view/{view_id}")] async fn get_release_view_api( path: Path, @@ -538,17 +539,10 @@ async fn get_release_view_api( state: web::Data, ) -> airborne_types::Result> { let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), WRITE) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; let view_id_str = path.into_inner(); let view_id = Uuid::parse_str(&view_id_str) @@ -580,6 +574,12 @@ async fn get_release_view_api( })) } +#[authz( + resource = "release_view", + action = "update", + org_roles = ["owner", "admin", "write"], + app_roles = ["admin", "write"] +)] #[put("/release-view/{view_id}")] async fn update_release_view_api( path: Path, @@ -588,17 +588,10 @@ async fn update_release_view_api( state: web::Data, ) -> airborne_types::Result> { let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), WRITE) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; let view_id_str = path.into_inner(); let view_id = Uuid::parse_str(&view_id_str) @@ -690,6 +683,12 @@ async fn update_release_view_api( })) } +#[authz( + resource = "release_view", + action = "delete", + org_roles = ["owner", "admin", "write"], + app_roles = ["admin", "write"] +)] #[delete("/release-view/{view_id}")] async fn delete_release_view_api( path: Path, @@ -697,17 +696,10 @@ async fn delete_release_view_api( state: web::Data, ) -> airborne_types::Result> { let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), WRITE) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; let view_id_str = path.into_inner(); let view_id = Uuid::parse_str(&view_id_str) @@ -718,12 +710,10 @@ async fn delete_release_view_api( let deleted_rows = run_blocking!({ let mut conn = pool.get()?; let rows = diesel::delete( - release_views::table.filter( - app_id - .eq(&application) - .and(org_id.eq(&organisation)) - .and(id.eq(&view_id)), - ), + release_views::table + .filter(app_id.eq(&application)) + .filter(org_id.eq(&organisation)) + .filter(id.eq(&view_id)), ) .execute(&mut conn) .map_err(|e| ABError::InternalServerError(format!("Failed to delete view: {}", e)))?; diff --git a/airborne_server/src/organisation/application/dimension/cohort.rs b/airborne_server/src/organisation/application/dimension/cohort.rs index a5c07b8f..7f951883 100644 --- a/airborne_server/src/organisation/application/dimension/cohort.rs +++ b/airborne_server/src/organisation/application/dimension/cohort.rs @@ -5,10 +5,11 @@ use actix_web::{ web::{self, Json, Path, ReqData}, Scope, }; +use airborne_authz_macros::authz; use log::info; use crate::{ - middleware::auth::{validate_user, AuthResponse, ADMIN, READ, WRITE}, + middleware::auth::{require_org_and_app, AuthResponse}, organisation::application::dimension::cohort::types::CohortDimensionSchema, types as airborne_types, types::{ABError, AppState}, @@ -26,6 +27,12 @@ pub fn add_routes() -> Scope { .service(update_cohort_priority_api) } +#[authz( + resource = "cohort", + action = "read", + org_roles = ["owner", "admin", "write", "read"], + app_roles = ["admin", "write", "read"] +)] #[get("")] async fn list_cohorts_api( cohort_dimension: Path, @@ -35,17 +42,10 @@ async fn list_cohorts_api( let cohort_dimension_id = cohort_dimension.into_inner(); let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), READ) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; // Get workspace name for this application let workspace_name = crate::utils::workspace::get_workspace_name_for_application( @@ -79,6 +79,12 @@ async fn list_cohorts_api( Ok(Json(cohort_dimension)) } +#[authz( + resource = "cohort", + action = "update", + org_roles = ["owner", "admin", "write"], + app_roles = ["admin", "write"] +)] #[post("/checkpoint")] async fn create_cohort_checkpoint_api( cohort_dimension: Path, @@ -88,17 +94,10 @@ async fn create_cohort_checkpoint_api( ) -> airborne_types::Result> { let cohort_dimension_id = cohort_dimension.into_inner(); let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Unauthorized("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), WRITE) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; // Get workspace name for this application let workspace_name = crate::utils::workspace::get_workspace_name_for_application( @@ -254,6 +253,12 @@ async fn create_cohort_checkpoint_api( })) } +#[authz( + resource = "cohort_group", + action = "create", + org_roles = ["owner", "admin", "write"], + app_roles = ["admin", "write"] +)] #[post("/group")] async fn create_cohort_group_api( cohort_dimension: Path, @@ -264,17 +269,10 @@ async fn create_cohort_group_api( let cohort_dimension_id = cohort_dimension.into_inner(); let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), WRITE) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; // Get workspace name for this application let workspace_name = crate::utils::workspace::get_workspace_name_for_application( @@ -355,6 +353,12 @@ async fn create_cohort_group_api( })) } +#[authz( + resource = "cohort_group", + action = "read", + org_roles = ["owner", "admin", "write", "read"], + app_roles = ["admin", "write", "read"] +)] #[get("/group/priority")] async fn get_cohort_priority_api( cohort_dimension: Path, @@ -363,17 +367,10 @@ async fn get_cohort_priority_api( ) -> airborne_types::Result> { let cohort_dimension = cohort_dimension.into_inner(); let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), READ) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; // Get workspace name for this application let workspace_name = crate::utils::workspace::get_workspace_name_for_application( @@ -428,6 +425,12 @@ async fn get_cohort_priority_api( Ok(Json(types::GetPriorityOutput { priority_map })) } +#[authz( + resource = "cohort_group", + action = "update", + org_roles = ["owner", "admin", "write"], + app_roles = ["admin", "write"] +)] #[put("/group/priority")] async fn update_cohort_priority_api( cohort_dimension: Path, @@ -438,17 +441,10 @@ async fn update_cohort_priority_api( let cohort_dimension_id = cohort_dimension.into_inner(); let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), WRITE) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; // Get workspace name for this application let workspace_name = crate::utils::workspace::get_workspace_name_for_application( diff --git a/airborne_server/src/organisation/application/properties.rs b/airborne_server/src/organisation/application/properties.rs index bfca0c42..a08cdfb5 100644 --- a/airborne_server/src/organisation/application/properties.rs +++ b/airborne_server/src/organisation/application/properties.rs @@ -15,7 +15,7 @@ use std::collections::{BTreeMap, HashMap}; use crate::{ - middleware::auth::{validate_user, AuthResponse, ADMIN, READ, WRITE}, + middleware::auth::{require_org_and_app, AuthResponse}, organisation::application::properties::types::ConfigProperty, release::utils::parse_kv_string, types as airborne_types, @@ -32,6 +32,7 @@ use actix_web::{ web::{Data, Json, ReqData}, Scope, }; +use airborne_authz_macros::authz; use aws_smithy_types::Document; use http::{uri::PathAndQuery, Uri}; use log::info; @@ -54,6 +55,12 @@ pub fn add_routes() -> Scope { .service(list_properties_api) } +#[authz( + resource = "property_schema", + action = "update", + org_roles = ["owner", "admin", "write"], + app_roles = ["admin", "write"] +)] #[put("/schema")] async fn put_properties_schema_api( req: Json, @@ -61,17 +68,10 @@ async fn put_properties_schema_api( state: Data, ) -> airborne_types::Result> { let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), WRITE) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; let properties = req.properties.clone(); let properties = properties @@ -436,6 +436,12 @@ async fn rollback_config_update( } } +#[authz( + resource = "property_schema", + action = "read", + org_roles = ["owner", "admin", "write", "read"], + app_roles = ["admin", "write", "read"] +)] #[get("/schema")] async fn get_properties_schema_api( req: actix_web::HttpRequest, @@ -443,17 +449,10 @@ async fn get_properties_schema_api( state: Data, ) -> airborne_types::Result> { let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), READ) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; let workspace_name = crate::utils::workspace::get_workspace_name_for_application( state.db_pool.clone(), @@ -599,6 +598,12 @@ async fn get_properties_schema_api( })) } +#[authz( + resource = "property", + action = "read", + org_roles = ["owner", "admin", "write", "read"], + app_roles = ["admin", "write", "read"] +)] #[get("/list")] async fn list_properties_api( auth_response: ReqData, @@ -606,17 +611,10 @@ async fn list_properties_api( state: Data, ) -> airborne_types::Result> { let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), READ) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; let workspace_name = crate::utils::workspace::get_workspace_name_for_application( state.db_pool.clone(), diff --git a/airborne_server/src/organisation/application/types.rs b/airborne_server/src/organisation/application/types.rs index 8b83ce88..6198076f 100644 --- a/airborne_server/src/organisation/application/types.rs +++ b/airborne_server/src/organisation/application/types.rs @@ -1,52 +1,4 @@ -use http::StatusCode; use serde::{Deserialize, Serialize}; -use thiserror::Error; - -use crate::{ - impl_response_error, - types::{ABErrorCodes, AppError, HasLabel}, -}; - -/// Errors that can occur during application operations -#[derive(Error, Debug)] -pub enum OrgAppError { - #[error("User not found: {0}")] - UserNotFound(String), - - #[error("Organisation not found: {0}")] - OrgNotFound(String), - - #[error("Application not found: {0}")] - AppNotFound(String), - - #[error("Invalid access level: {0}")] - InvalidAccessLevel(String), - - #[error("Permission denied: {0}")] - PermissionDenied(String), -} - -impl AppError for OrgAppError { - fn code(&self) -> &'static str { - match self { - OrgAppError::UserNotFound(_) => ABErrorCodes::NotFound.label(), - OrgAppError::OrgNotFound(_) => ABErrorCodes::NotFound.label(), - OrgAppError::AppNotFound(_) => ABErrorCodes::NotFound.label(), - OrgAppError::InvalidAccessLevel(_) => ABErrorCodes::Unauthorized.label(), - OrgAppError::PermissionDenied(_) => ABErrorCodes::Unauthorized.label(), - } - } - fn status_code(&self) -> StatusCode { - match self { - OrgAppError::UserNotFound(_) => StatusCode::NOT_FOUND, - OrgAppError::OrgNotFound(_) => StatusCode::NOT_FOUND, - OrgAppError::AppNotFound(_) => StatusCode::NOT_FOUND, - OrgAppError::InvalidAccessLevel(_) => StatusCode::BAD_REQUEST, - OrgAppError::PermissionDenied(_) => StatusCode::FORBIDDEN, - } - } -} -impl_response_error!(OrgAppError); #[derive(Serialize, Deserialize)] pub struct Application { diff --git a/airborne_server/src/organisation/application/user.rs b/airborne_server/src/organisation/application/user.rs index 107e9017..2e867bed 100644 --- a/airborne_server/src/organisation/application/user.rs +++ b/airborne_server/src/organisation/application/user.rs @@ -12,33 +12,21 @@ // See the License for the specific language governing permissions and // limitations under the License. -mod transaction; mod types; -mod utils; use actix_web::{ get, post, - web::{self, Json}, + web::{self, Json, ReqData}, HttpMessage, HttpRequest, Scope, }; -use log::{debug, info}; +use airborne_authz_macros::authz; +use log::info; use crate::{ - middleware::auth::{ - validate_required_access, validate_user, Access, AuthResponse, ADMIN, READ, - }, - organisation::application::{types::OrgAppError, user::types::*}, + middleware::auth::{require_org_and_app, AuthResponse}, + organisation::application::user::types::*, types as airborne_types, types::{ABError, AppState}, - utils::keycloak::{find_org_group, find_user_by_username, prepare_user_action}, -}; - -use self::{ - transaction::{ - add_user_with_transaction, get_user_current_role, remove_user_with_transaction, - update_user_with_transaction, - }, - utils::{check_role_hierarchy, is_last_admin_in_application, validate_access_level}, }; pub fn add_routes() -> Scope { @@ -47,269 +35,98 @@ pub fn add_routes() -> Scope { .service(application_add_user) .service(application_update_user) .service(application_remove_user) + .service(list_application_roles) + .service(list_application_permissions) + .service(upsert_application_role) } -/// Get application context and validate user permissions async fn get_app_context( req: &HttpRequest, - required_level: Access, - operation: &str, -) -> airborne_types::Result<(AppContext, AuthResponse)> { +) -> airborne_types::Result<(String, String, AuthResponse)> { let auth = req .extensions() .get::() .cloned() - .ok_or_else(|| ABError::Unauthorized("Missing auth".to_string()))?; - - // For application operations, we need to check: - // 1. User has some access to the organization (at least READ) - // 2. User has the required access level to the application - - let (org_name, app_name) = match validate_user(auth.organisation.clone(), ADMIN) { - Ok(org_name) => auth - .application - .clone() - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth.organisation.clone(), READ) - .and_then(|org_name| { - validate_user(auth.application.clone(), required_level) - .map(|app_name| (org_name, app_name)) - }) - .map_err(|e| ABError::Forbidden(e.to_string())), - }?; - - // For application admin operations, application-level permissions take precedence - // over organization-level permissions - if let Some(app_access) = &auth.application { - if app_access.level >= required_level.access { - // User has sufficient application-level access - return Ok(( - AppContext { - org_name: org_name.clone(), - app_name: app_name.clone(), - app_group_id: String::new(), // Will be filled by find_application - }, - auth, - )); - } - } + .ok_or_else(|| ABError::Unauthorized("Missing auth context".to_string()))?; - // Fallback: check organization-level permissions for the operation - validate_required_access(&auth, required_level.access, operation).await?; - - Ok(( - AppContext { - org_name: org_name.clone(), - app_name: app_name.clone(), - app_group_id: String::new(), // Will be filled by find_application - }, - auth, - )) -} - -/// Find a user and extract their ID -async fn find_target_user( - admin: &keycloak::KeycloakAdmin, - realm: &str, - username: &str, -) -> airborne_types::Result { - let target_user = find_user_by_username(admin, realm, username) - .await? - .ok_or_else(|| OrgAppError::UserNotFound(username.to_string()))?; - - let target_user_id = target_user - .id - .as_ref() - .ok_or_else(|| ABError::InternalServerError("User has no ID".to_string()))? - .to_string(); - - let username = target_user - .username - .as_ref() - .ok_or_else(|| ABError::InternalServerError("User has no username".to_string()))? - .to_string(); - - Ok(UserContext { - user_id: target_user_id, - username, - }) -} - -/// Find an application and extract its context -async fn find_application( - admin: &keycloak::KeycloakAdmin, - realm: &str, - org_name: &str, - app_name: &str, -) -> airborne_types::Result { - // First find the organization group - let org_group = find_org_group(admin, realm, org_name) - .await? - .ok_or_else(|| OrgAppError::OrgNotFound(org_name.to_string()))?; - - let org_group_id = org_group - .id - .as_ref() - .ok_or_else(|| ABError::InternalServerError("Organization group has no ID".to_string()))? - .to_string(); - - // Find the application subgroup within the organization - let app_subgroups = admin - .realm_groups_with_group_id_children_get(realm, &org_group_id, None, None, None, None, None) - .await?; - - let app_group = app_subgroups - .iter() - .find(|group| { - if let Some(name) = &group.name { - name == app_name - } else { - false - } - }) - .ok_or_else(|| OrgAppError::AppNotFound(app_name.to_string()))?; - - let app_group_id = app_group - .id - .as_ref() - .ok_or_else(|| ABError::InternalServerError("Application group has no ID".to_string()))? - .to_string(); - - Ok(AppContext { - org_name: org_name.to_string(), - app_name: app_name.to_string(), - app_group_id, - }) + let (org_name, app_name) = + require_org_and_app(auth.organisation.clone(), auth.application.clone())?; + Ok((org_name, app_name, auth)) } +#[authz( + resource = "application_user", + action = "create", + org_roles = ["owner", "admin"], + app_roles = ["admin"] +)] #[post("/create")] async fn application_add_user( req: HttpRequest, body: Json, + auth_response: ReqData, state: web::Data, ) -> airborne_types::Result> { - let body = body.into_inner(); - - // Get application context and validate requester's permissions - let (mut app_context, auth) = get_app_context(&req, ADMIN, "add user").await?; - let requester_id = &auth.sub; - - // Prepare Keycloak admin client - let (admin, realm) = prepare_user_action(&req, state.clone()).await?; - - // Validate access level (only admin, write, read for applications) - let (role_name, _role_level) = validate_access_level(&body.access.as_str())?; - - // Find target user and application in parallel - let (target_user, filled_app_context) = tokio::join!( - find_target_user(&admin, &realm, &body.user), - find_application(&admin, &realm, &app_context.org_name, &app_context.app_name) - ); - - let target_user = target_user?; - app_context = filled_app_context?; - - // Check role hierarchy - check_role_hierarchy( - &admin, - &realm, - &app_context.app_group_id, - requester_id, - &target_user.user_id, - ) - .await?; - - debug!( - "Adding user {} to app {}/{} with access level {}", - body.user, app_context.org_name, app_context.app_name, role_name - ); - - // Use transaction function to add user - add_user_with_transaction(&admin, &realm, &app_context, &target_user, &role_name).await?; + let request = body.into_inner(); + let (org_name, app_name, auth) = get_app_context(&req).await?; + let role_name = request.access.trim(); + + state + .authz_provider + .add_application_user( + state.get_ref(), + &auth.sub, + &org_name, + &app_name, + &request.user, + role_name, + ) + .await?; info!( - "Successfully added user {} to app {}/{} with access level {}", - body.user, app_context.org_name, app_context.app_name, role_name + "Added app user {} in {}/{} with role {}", + request.user, org_name, app_name, role_name ); Ok(Json(UserOperationResponse { - user: body.user, + user: request.user, success: true, operation: "add".to_string(), })) } +#[authz( + resource = "application_user", + action = "update", + org_roles = ["owner", "admin"], + app_roles = ["admin"] +)] #[post("/update")] async fn application_update_user( req: HttpRequest, body: Json, + auth_response: ReqData, state: web::Data, ) -> airborne_types::Result> { let request = body.into_inner(); - - // Get application context and validate requester's permissions - let (mut app_context, auth) = get_app_context(&req, ADMIN, "update user").await?; - let requester_id = &auth.sub; - - // Prepare Keycloak admin client - let (admin, realm) = prepare_user_action(&req, state.clone()).await?; - - // Validate the requested access level - let (role_name, _access_level) = validate_access_level(&request.access.as_str())?; - - // Find target user and application - let target_user = find_target_user(&admin, &realm, &request.user).await?; - app_context = - find_application(&admin, &realm, &app_context.org_name, &app_context.app_name).await?; - - // Check if requester has permission to modify this user (hierarchy check) - check_role_hierarchy( - &admin, - &realm, - &app_context.app_group_id, - requester_id, - &target_user.user_id, - ) - .await?; - - // Get the user's current role for the transaction - let current_role = - get_user_current_role(&admin, &realm, &app_context, &target_user.user_id).await?; - - // Check if this would demote the last admin (can't do that) - if current_role == "admin" && role_name != "admin" { - let is_last_admin = is_last_admin_in_application( - &admin, - &realm, - &app_context.app_group_id, - &target_user.user_id, + let (org_name, app_name, auth) = get_app_context(&req).await?; + let role_name = request.access.trim(); + + state + .authz_provider + .update_application_user( + state.get_ref(), + &auth.sub, + &org_name, + &app_name, + &request.user, + role_name, ) .await?; - if is_last_admin { - return Err(OrgAppError::PermissionDenied( - "Cannot demote the last admin from the application. Applications must have at least one admin.".to_string(), - ) - .into()); - } - } - - // Use transaction function to update user - update_user_with_transaction( - &admin, - &realm, - &app_context, - &target_user, - &role_name, - ¤t_role, - &state, - ) - .await?; - info!( - "Successfully updated user {} in app {}/{} to role {}", - request.user, app_context.org_name, app_context.app_name, role_name + "Updated app user {} in {}/{} to role {}", + request.user, org_name, app_name, role_name ); Ok(Json(UserOperationResponse { @@ -319,71 +136,36 @@ async fn application_update_user( })) } +#[authz( + resource = "application_user", + action = "delete", + org_roles = ["owner", "admin"], + app_roles = ["admin"] +)] #[post("/remove")] async fn application_remove_user( req: HttpRequest, body: Json, + auth_response: ReqData, state: web::Data, ) -> airborne_types::Result> { let request = body.into_inner(); - - // Get application context and validate requester's permissions - let (mut app_context, auth) = get_app_context(&req, ADMIN, "remove user").await?; - let requester_id = &auth.sub; - - // Prepare Keycloak admin client - let (admin, realm) = prepare_user_action(&req, state.clone()).await?; - - // Find target user and application - let target_user = find_target_user(&admin, &realm, &request.user).await?; - app_context = - find_application(&admin, &realm, &app_context.org_name, &app_context.app_name).await?; - - // Check if this user is the last admin in the application (can't remove them) - let is_last_admin = is_last_admin_in_application( - &admin, - &realm, - &app_context.app_group_id, - &target_user.user_id, - ) - .await?; - - if is_last_admin { - return Err(OrgAppError::PermissionDenied( - "Cannot remove the last admin from the application. Applications must have at least one admin.".to_string(), + let (org_name, app_name, auth) = get_app_context(&req).await?; + + state + .authz_provider + .remove_application_user( + state.get_ref(), + &auth.sub, + &org_name, + &app_name, + &request.user, ) - .into()); - } - - // Check if requester has permission to modify this user (hierarchy check) - check_role_hierarchy( - &admin, - &realm, - &app_context.app_group_id, - requester_id, - &target_user.user_id, - ) - .await?; - - // Get user's current groups - let user_groups = admin - .realm_users_with_user_id_groups_get(&realm, &target_user.user_id, None, None, None, None) .await?; - // Use transaction function to remove user - remove_user_with_transaction( - &admin, - &realm, - &app_context, - &target_user, - &user_groups, - &state, - ) - .await?; - info!( - "Successfully removed user {} from application {}/{}", - request.user, app_context.org_name, app_context.app_name + "Removed app user {} from {}/{}", + request.user, org_name, app_name ); Ok(Json(UserOperationResponse { @@ -393,102 +175,133 @@ async fn application_remove_user( })) } +#[authz( + resource = "application_user", + action = "read", + org_roles = ["owner", "admin", "write", "read"], + app_roles = ["admin", "write", "read"] +)] #[get("/list")] async fn application_list_users( req: HttpRequest, + auth_response: ReqData, state: web::Data, ) -> airborne_types::Result> { - // Get application context and validate requester's permissions - let (app_context, _) = get_app_context(&req, READ, "list users").await?; - - // Prepare Keycloak admin client - let (admin, realm) = prepare_user_action(&req, state).await?; - - // Find the application - let app_context = - find_application(&admin, &realm, &app_context.org_name, &app_context.app_name).await?; - - debug!( - "Listing users for application: {}/{} (ID: {})", - app_context.org_name, app_context.app_name, app_context.app_group_id - ); - - // Get all users in the realm - let all_users = admin - .realm_users_get( - &realm, - Some(true), // briefRepresentation - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - ) + let (org_name, app_name, _) = get_app_context(&req).await?; + let users = state + .authz_provider + .list_application_users(state.get_ref(), &org_name, &app_name) .await?; - // Collect information about users in this application - let mut user_infos = Vec::new(); - let app_path = format!("/{}/{}/", app_context.org_name, app_context.app_name); - - for user in all_users { - if let Some(user_id) = user.id.as_ref() { - // Get groups for this user - let user_groups = admin - .realm_users_with_user_id_groups_get(&realm, user_id, None, None, None, None) - .await?; - - // Check if user is in this application - let is_member = user_groups.iter().any(|group| { - group - .path - .as_ref() - .is_some_and(|path| path.contains(&app_path)) - }); + Ok(Json(ListUsersResponse { + users: users + .into_iter() + .map(|user| UserInfo { + username: user.username, + email: user.email, + roles: user.roles, + }) + .collect(), + })) +} - if is_member { - let username = user.username.as_ref().ok_or_else(|| { - ABError::InternalServerError("User has no username".to_string()) - })?; +#[authz( + resource = "application_role", + action = "read", + org_roles = ["owner", "admin"], + app_roles = ["admin"] +)] +#[get("/roles/list")] +async fn list_application_roles( + req: HttpRequest, + auth_response: ReqData, + state: web::Data, +) -> airborne_types::Result> { + let (org_name, app_name, auth) = get_app_context(&req).await?; + let roles = state + .authz_provider + .list_role_definitions(state.get_ref(), &auth.sub, &org_name, Some(&app_name)) + .await?; - // Extract roles from group paths - let roles = user_groups - .iter() - .filter_map(|group| { - if let Some(path) = &group.path { - if path.starts_with(&format!( - "/{}/{}/", - app_context.org_name, app_context.app_name - )) { - return path.split('/').next_back().map(String::from); - } - } - None + Ok(Json(ListRolesResponse { + roles: roles + .into_iter() + .map(|role| RoleInfo { + role: role.role, + is_system: role.is_system, + permissions: role + .permissions + .into_iter() + .map(|permission| PermissionInfo { + key: permission.key, + resource: permission.resource, + action: permission.action, }) - .collect(); + .collect(), + }) + .collect(), + })) +} + +#[authz( + resource = "application_role", + action = "read", + org_roles = ["owner", "admin"], + app_roles = ["admin"] +)] +#[get("/permissions/list")] +async fn list_application_permissions( + req: HttpRequest, + auth_response: ReqData, + state: web::Data, +) -> airborne_types::Result> { + let (org_name, app_name, auth) = get_app_context(&req).await?; + let permissions = state + .authz_provider + .list_available_permissions(state.get_ref(), &auth.sub, &org_name, Some(&app_name)) + .await?; - user_infos.push(UserInfo { - username: username.clone(), - email: user.email.clone(), - roles, - }); - } - } - } + Ok(Json(ListPermissionsResponse { + permissions: permissions + .into_iter() + .map(|permission| PermissionInfo { + key: permission.key, + resource: permission.resource, + action: permission.action, + }) + .collect(), + })) +} - info!( - "Found {} users in application {}/{}", - user_infos.len(), - app_context.org_name, - app_context.app_name - ); +#[authz( + resource = "application_role", + action = "create", + org_roles = ["owner", "admin"], + app_roles = ["admin"] +)] +#[post("/roles/upsert")] +async fn upsert_application_role( + req: HttpRequest, + body: Json, + auth_response: ReqData, + state: web::Data, +) -> airborne_types::Result> { + let payload = body.into_inner(); + let (org_name, app_name, auth) = get_app_context(&req).await?; + state + .authz_provider + .upsert_custom_role( + state.get_ref(), + &auth.sub, + &org_name, + Some(&app_name), + payload.role.trim(), + &payload.permissions, + ) + .await?; - Ok(Json(ListUsersResponse { users: user_infos })) + Ok(Json(serde_json::json!({ + "success": true, + "role": payload.role.trim().to_ascii_lowercase(), + }))) } diff --git a/airborne_server/src/organisation/application/user/transaction.rs b/airborne_server/src/organisation/application/user/transaction.rs deleted file mode 100644 index 72c9a2fe..00000000 --- a/airborne_server/src/organisation/application/user/transaction.rs +++ /dev/null @@ -1,489 +0,0 @@ -// Copyright 2025 Juspay Technologies -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use crate::{ - types as airborne_types, - types::{ABError, AppState}, - utils::{keycloak::find_role_subgroup, transaction_manager::TransactionManager}, -}; -use actix_web::web; -use keycloak::KeycloakAdmin; -use log::{debug, error, info, warn}; - -use super::{AppContext, OrgAppError, UserContext}; - -/// Add a user to an application with transaction management -pub async fn add_user_with_transaction( - admin: &KeycloakAdmin, - realm: &str, - app_context: &AppContext, - target_user: &UserContext, - role_name: &str, -) -> airborne_types::Result<()> { - let _user_org_currenet_rol = - get_user_current_role_in_org(admin, realm, &app_context.org_name, &target_user.user_id) - .await - .map_err(|_| OrgAppError::UserNotFound("User not found in org".to_string()))?; - - // Create a new transaction manager for this operation - let transaction = TransactionManager::new( - &format!("{}/{}", app_context.org_name, app_context.app_name), - "application_user", - ); - - debug!( - "Starting transaction to add user {} to app {}/{} with role {}", - target_user.username, app_context.org_name, app_context.app_name, role_name - ); - - let role_name = role_name.trim().to_ascii_lowercase(); - - // Get additional roles based on the requested role - let mut roles = get_additional_roles(&role_name).await?; - - roles.push(role_name.to_string()); - - // Add user to each role group - for role_name in roles { - add_user_to_group( - admin, - realm, - &target_user.user_id, - &app_context.app_group_id, - &role_name, - &transaction, - ) - .await?; - } - - // Mark transaction as complete - transaction.set_database_inserted(); - - info!( - "Successfully completed transaction to add user {} to app {}/{}", - target_user.username, app_context.org_name, app_context.app_name - ); - - Ok(()) -} - -/// Roles come with additional roles like Admin has Write, Read -/// This function retrieves those additional roles for a given role name -async fn get_additional_roles(role_name: &str) -> airborne_types::Result> { - let additional_roles = match role_name { - "admin" => vec!["write".to_string(), "read".to_string()], - "write" => vec!["read".to_string()], - _ => vec![], - }; - - if additional_roles.is_empty() && role_name != "read" { - return Err(ABError::InternalServerError(format!( - "No additional roles found for role {}", - role_name - ))); - } - - Ok(additional_roles) -} - -async fn add_user_to_group( - admin: &KeycloakAdmin, - realm: &str, - user_id: &str, - group_id: &str, - role_name: &str, - transaction: &TransactionManager, -) -> airborne_types::Result<()> { - // Find the role group - let role_group = find_role_subgroup(admin, realm, group_id, role_name) - .await? - .ok_or_else(|| { - ABError::InternalServerError(format!("Role group {} not found", role_name)) - })?; - - let role_group_id = role_group - .id - .as_ref() - .ok_or_else(|| ABError::InternalServerError("Role group has no ID".to_string()))? - .to_string(); - - // Add user to role group - match admin - .realm_users_with_user_id_groups_with_group_id_put(realm, user_id, &role_group_id) - .await - { - Ok(_) => { - // Record this resource in the transaction - transaction.add_keycloak_resource( - "user_group_membership", - &format!("{}:{}", user_id, role_group_id), - ); - debug!("Added user {} to role group {}", user_id, role_name); - } - Err(e) => { - // If this fails, there's nothing to roll back yet - return Err(ABError::InternalServerError(format!( - "Failed to add user to role group: {}", - e - ))); - } - } - Ok(()) -} - -async fn remove_user_from_group( - admin: &KeycloakAdmin, - realm: &str, - user_id: &str, - group_id: &str, - role_name: &str, - transaction: &TransactionManager, -) -> airborne_types::Result<()> { - // Find the role group - let role_group = find_role_subgroup(admin, realm, group_id, role_name) - .await? - .ok_or_else(|| { - ABError::InternalServerError(format!("Role group {} not found", role_name)) - })?; - - let role_group_id = role_group - .id - .as_ref() - .ok_or_else(|| ABError::InternalServerError("Role group has no ID".to_string()))? - .to_string(); - - // Add user to role group - match admin - .realm_users_with_user_id_groups_with_group_id_delete(realm, user_id, &role_group_id) - .await - { - Ok(_) => { - // Record this resource in the transaction - transaction.add_keycloak_resource( - "user_group_membership", - &format!("{}:{}", user_id, role_group_id), - ); - } - Err(e) => { - // If this fails, there's nothing to roll back yet - return Err(ABError::InternalServerError(format!( - "Failed to add user to role group: {}", - e - ))); - } - } - Ok(()) -} - -/// Update a user's role in an application with transaction management -pub async fn update_user_with_transaction( - admin: &KeycloakAdmin, - realm: &str, - app_context: &AppContext, - target_user: &UserContext, - new_role_name: &str, - current_role: &str, - _state: &web::Data, -) -> airborne_types::Result<()> { - // Create a new transaction manager - let transaction = TransactionManager::new( - &format!("{}/{}", app_context.org_name, app_context.app_name), - "application_user_update", - ); - - debug!( - "Starting transaction to update user {} in app {}/{} from role {} to {}", - target_user.username, - app_context.org_name, - app_context.app_name, - current_role, - new_role_name - ); - - let mut new_roles = get_additional_roles(new_role_name).await?; - new_roles.push(new_role_name.to_string()); - - let mut current_roles = get_additional_roles(current_role).await?; - current_roles.push(current_role.to_string()); - - let to_add: Vec<_> = new_roles - .iter() - .filter(|r| !current_roles.contains(r)) - .cloned() - .collect(); - - let to_remove: Vec<_> = current_roles - .iter() - .filter(|r| !new_roles.contains(r)) - .cloned() - .collect(); - - for role_name in to_add { - add_user_to_group( - admin, - realm, - &target_user.user_id, - &app_context.app_group_id, - &role_name, - &transaction, - ) - .await - .map_err(|e| ABError::InternalServerError(format!("Failed to add user to group: {}", e)))?; - } - - // Remove roles no longer needed - for role_name in to_remove { - remove_user_from_group( - admin, - realm, - &target_user.user_id, - &app_context.app_group_id, - &role_name, - &transaction, - ) - .await - .map_err(|e| { - ABError::InternalServerError(format!("Failed to remove user from group: {}", e)) - })?; - } - - // Mark transaction as complete - transaction.set_database_inserted(); - - info!( - "Successfully completed transaction to update user {} in app {}/{} from role {} to {}", - target_user.username, - app_context.org_name, - app_context.app_name, - current_role, - new_role_name - ); - - Ok(()) -} - -/// Remove a user from an application with transaction management -pub async fn remove_user_with_transaction( - admin: &KeycloakAdmin, - realm: &str, - app_context: &AppContext, - target_user: &UserContext, - user_groups: &[keycloak::types::GroupRepresentation], - state: &web::Data, -) -> airborne_types::Result<()> { - // Create a new transaction manager - let transaction = TransactionManager::new( - &format!("{}/{}", app_context.org_name, app_context.app_name), - "application_user_remove", - ); - - debug!( - "Starting transaction to remove user {} from app {}/{}", - target_user.username, app_context.org_name, app_context.app_name - ); - - // Filter groups that belong to this application - let app_path = format!("/{}/{}/", app_context.org_name, app_context.app_name); - let app_groups: Vec<_> = user_groups - .iter() - .filter(|g| g.path.as_ref().is_some_and(|p| p.contains(&app_path))) - .collect(); - - if app_groups.is_empty() { - return Err(ABError::InternalServerError(format!( - "User {} is not a member of any groups in application {}/{}", - target_user.username, app_context.org_name, app_context.app_name - ))); - } - - // Keep track of groups we've removed the user from (for potential rollback) - let mut removed_groups = Vec::new(); - - // Remove user from all application groups - for group in app_groups { - if let (Some(path), Some(group_id)) = (&group.path, &group.id) { - debug!( - "Removing user {} from group: {}", - target_user.username, path - ); - - match admin - .realm_users_with_user_id_groups_with_group_id_delete( - realm, - &target_user.user_id, - group_id, - ) - .await - { - Ok(_) => { - debug!("Successfully removed user from group: {}", path); - removed_groups.push(group.clone()); - transaction.add_keycloak_resource( - "user_group_removal", - &format!("{}:{}", target_user.user_id, group_id), - ); - } - Err(e) => { - warn!( - "Failed to remove user from group {}: {}. Attempting rollback...", - path, e - ); - - // Attempt to rollback by adding user back to removed groups - let mut rollback_failed = false; - for removed_group in &removed_groups { - if let Some(removed_id) = &removed_group.id { - if let Err(rollback_err) = admin - .realm_users_with_user_id_groups_with_group_id_put( - realm, - &target_user.user_id, - removed_id, - ) - .await - { - error!( - "Rollback failed for group {}: {}", - removed_group - .path - .as_ref() - .unwrap_or(&"unknown".to_string()), - rollback_err - ); - rollback_failed = true; - } - } - } - - // If rollback failed, record for future cleanup - if rollback_failed { - if let Err(record_err) = - record_failed_cleanup(state, &transaction.get_state()).await - { - error!("Failed to record cleanup job: {}", record_err); - } - } - - return Err(ABError::InternalServerError(format!( - "Failed to remove user from group {}: {}", - path, e - ))); - } - } - } - } - - // Mark transaction as complete - transaction.set_database_inserted(); - - info!( - "Successfully completed transaction to remove user {} from app {}/{}", - target_user.username, app_context.org_name, app_context.app_name - ); - - Ok(()) -} - -/// Get a user's current role in an application -pub async fn get_user_current_role( - admin: &KeycloakAdmin, - realm: &str, - app_context: &AppContext, - user_id: &str, -) -> airborne_types::Result { - // Get user's groups - let user_groups = admin - .realm_users_with_user_id_groups_get(realm, user_id, None, None, None, None) - .await?; - - // Find role groups under this application - let app_path = format!("/{}/{}/", app_context.org_name, app_context.app_name); - let mut user_roles = Vec::new(); - // Find the role group the user is in - for group in user_groups { - if let Some(path) = group.path { - if path.starts_with(&app_path) && path != app_path { - // Extract role name from path - if let Some(role) = path.split('/').next_back() { - if !role.is_empty() { - user_roles.push(role.to_string()); - } - } - } - } - } - let hierarchy = ["owner", "admin", "write", "read"]; - for &role in &hierarchy { - if user_roles.contains(&role.to_string()) { - return Ok(role.to_string()); - } - } - - Err(ABError::InternalServerError(format!( - "User has no role in application {}/{}", - app_context.org_name, app_context.app_name - ))) -} - -/// Record failed cleanup for future processing -async fn record_failed_cleanup( - _state: &web::Data, - _transaction_state: &crate::utils::transaction_manager::TransactionState, -) -> Result<(), Box> { - // This would normally insert a record into a cleanup table - // For now, we'll just log the failure - warn!("Recording failed cleanup for future processing"); - Ok(()) -} - -/// Get a user's current role in an organization -pub async fn get_user_current_role_in_org( - admin: &KeycloakAdmin, - realm: &str, - org_context: &str, - user_id: &str, -) -> airborne_types::Result { - // Get user's groups - let user_groups = admin - .realm_users_with_user_id_groups_get(realm, user_id, None, None, None, None) - .await - .map_err(|e| ABError::InternalServerError(format!("Failed to get user groups: {}", e)))?; - - // Find role groups under this organization - let org_path = format!("/{}/", org_context); - let mut user_roles = Vec::new(); - // Find the role group the user is in - for group in user_groups { - if let Some(path) = group.path { - if path.starts_with(&org_path) && path != org_path { - // Extract role name from path - if let Some(role) = path.split('/').next_back() { - if !role.is_empty() { - user_roles.push(role.to_string()); - } - } - } - } - } - let hierarchy = ["owner", "admin", "write", "read"]; - for &role in &hierarchy { - if user_roles.contains(&role.to_string()) { - return Ok(role.to_string()); - } - } - - Err(ABError::InternalServerError(format!( - "User is not a member of any role in organization {}", - org_context - ))) -} diff --git a/airborne_server/src/organisation/application/user/types.rs b/airborne_server/src/organisation/application/user/types.rs index b8229633..206e0435 100644 --- a/airborne_server/src/organisation/application/user/types.rs +++ b/airborne_server/src/organisation/application/user/types.rs @@ -1,28 +1,10 @@ use serde::{Deserialize, Serialize}; // Request and Response Types -#[derive(Deserialize, Debug)] -#[serde(rename_all = "lowercase")] -pub enum AccessLvl { - Admin, - Write, - Read, -} - -impl AccessLvl { - pub fn as_str(&self) -> String { - match self { - Self::Admin => "admin".to_string(), - Self::Write => "write".to_string(), - Self::Read => "read".to_string(), - } - } -} - #[derive(Deserialize)] pub struct UserRequest { pub user: String, - pub access: AccessLvl, + pub access: String, } #[derive(Deserialize)] @@ -49,22 +31,32 @@ pub struct UserInfo { pub roles: Vec, } -// Helper structs +#[derive(Serialize, Clone)] +pub struct PermissionInfo { + pub key: String, + pub resource: String, + pub action: String, +} -pub struct UserContext { - pub user_id: String, - pub username: String, +#[derive(Serialize, Clone)] +pub struct RoleInfo { + pub role: String, + pub is_system: bool, + pub permissions: Vec, } -pub struct AppContext { - pub org_name: String, - pub app_name: String, - pub app_group_id: String, +#[derive(Serialize)] +pub struct ListRolesResponse { + pub roles: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum UserOperation { - Add, - Update, - Remove, +#[derive(Serialize)] +pub struct ListPermissionsResponse { + pub permissions: Vec, +} + +#[derive(Deserialize)] +pub struct UpsertRoleRequest { + pub role: String, + pub permissions: Vec, } diff --git a/airborne_server/src/organisation/application/user/utils.rs b/airborne_server/src/organisation/application/user/utils.rs deleted file mode 100644 index ca21ecf0..00000000 --- a/airborne_server/src/organisation/application/user/utils.rs +++ /dev/null @@ -1,170 +0,0 @@ -// Copyright 2025 Juspay Technologies -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use crate::{ - middleware::auth::{ADMIN, READ, WRITE}, - types as airborne_types, - types::ABError, -}; - -use super::OrgAppError; - -/// Get user's highest access level within an application group -pub fn get_user_highest_level( - groups: &[keycloak::types::GroupRepresentation], - app_group_id: &str, -) -> Option { - let mut highest = 0; - - for group in groups { - if let Some(parent_id) = &group.parent_id { - if parent_id != app_group_id { - continue; - } - - // Get the role name from the group name instead of path - if let Some(role) = &group.name { - if let Some(level) = match role.as_str() { - "read" => Some(READ.access), - "write" => Some(WRITE.access), - "admin" => Some(ADMIN.access), - _ => None, - } { - highest = highest.max(level); - } - } - } - } - - if highest > 0 { - Some(highest) - } else { - None - } -} - -/// Check role hierarchy to ensure requester can modify target user -pub async fn check_role_hierarchy( - admin: &keycloak::KeycloakAdmin, - realm: &str, - app_group_id: &str, - requester_id: &str, - target_user_id: &str, -) -> airborne_types::Result<()> { - if requester_id == target_user_id { - return Ok(()); - } - - let (requester_groups_result, target_groups_result) = tokio::join!( - admin.realm_users_with_user_id_groups_get(realm, requester_id, None, None, None, None), - admin.realm_users_with_user_id_groups_get(realm, target_user_id, None, None, None, None) - ); - - let requester_groups = requester_groups_result.map_err(|e| { - ABError::InternalServerError(format!("Failed to get requester groups: {}", e)) - })?; - - let target_groups = target_groups_result.map_err(|e| { - ABError::InternalServerError(format!("Failed to get target user groups: {}", e)) - })?; - - let requester_level = - get_user_highest_level(&requester_groups, app_group_id).ok_or_else(|| { - ABError::InternalServerError("Failed to determine requester's access level".to_string()) - })?; - - let target_level = get_user_highest_level(&target_groups, app_group_id).unwrap_or(0); - - if target_level > requester_level { - return Err(OrgAppError::PermissionDenied( - "Cannot modify users with higher access levels".into(), - ) - .into()); - } - - Ok(()) -} - -/// Validate access level string for applications (only admin, write, read) -pub fn validate_access_level(access: &str) -> airborne_types::Result<(String, u8)> { - match access.to_lowercase().as_str() { - "read" => Ok(("read".to_string(), READ.access)), - "write" => Ok(("write".to_string(), WRITE.access)), - "admin" => Ok(("admin".to_string(), ADMIN.access)), - _ => Err(OrgAppError::InvalidAccessLevel(format!( - "Invalid access level '{}'. Applications only support: read, write, admin", - access - )) - .into()), - } -} - -/// Check if user is the last admin in an application -pub async fn is_last_admin_in_application( - admin: &keycloak::KeycloakAdmin, - realm: &str, - app_group_id: &str, - user_id: &str, -) -> airborne_types::Result { - // Get all users in the realm - let all_users = admin - .realm_users_get( - realm, - Some(true), // briefRepresentation - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - ) - .await?; - - let mut admin_count = 0; - let mut target_user_is_admin = false; - - // Count users who are admins of this application - for user in all_users { - if let Some(current_user_id) = user.id.as_ref() { - // Get groups for this user - let user_groups = admin - .realm_users_with_user_id_groups_get(realm, current_user_id, None, None, None, None) - .await?; - - // Check if user has admin role in this application - let is_admin = user_groups.iter().any(|group| { - // Check if this group is an admin role group under this application - group.parent_id.as_ref() == Some(&app_group_id.to_string()) - && group.name.as_ref() == Some(&"admin".to_string()) - }); - - if is_admin { - admin_count += 1; - if current_user_id == user_id { - target_user_is_admin = true; - } - } - } - } - - // Return true if the target user is an admin and there's only one admin total - Ok(target_user_is_admin && admin_count <= 1) -} diff --git a/airborne_server/src/organisation/transaction.rs b/airborne_server/src/organisation/transaction.rs deleted file mode 100644 index c4734deb..00000000 --- a/airborne_server/src/organisation/transaction.rs +++ /dev/null @@ -1,249 +0,0 @@ -// Copyright 2025 Juspay Technologies -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use actix_web::web; -use keycloak::{types::GroupRepresentation, KeycloakAdmin}; -use log::{debug, error, info, warn}; - -use crate::{ - middleware::auth::ROLES, - types as airborne_types, - types::{ABError, AppState}, - utils::transaction_manager::TransactionManager, -}; - -use super::Organisation; - -pub async fn create_organisation_with_transaction( - organisation: &str, - admin: &KeycloakAdmin, - realm: &str, - user_id: &str, - state: &web::Data, -) -> airborne_types::Result { - // Create a transaction manager for this operation - let transaction = TransactionManager::new(organisation, "organization_create"); - - debug!( - "Starting transaction to create organization {}", - organisation - ); - - // Step 1: Create parent group in Keycloak - let group_id = match admin - .realm_groups_post( - realm, - GroupRepresentation { - name: Some(organisation.to_string()), - ..Default::default() - }, - ) - .await - { - Ok(id) => { - let group_id = id.unwrap_or_default(); - // Record this resource in the transaction - transaction.add_keycloak_group(&group_id); - debug!("Created organization parent group {}", group_id); - group_id - } - Err(e) => { - return Err(ABError::InternalServerError(format!( - "Failed to create organization group: {}", - e - ))) - } - }; - - // Step 2: Create role groups and add user to them - for role in ROLES { - match admin - .realm_groups_with_group_id_children_post( - realm, - &group_id, - GroupRepresentation { - name: Some(role.to_string()), - ..Default::default() - }, - ) - .await - { - Ok(id) => { - let role_id = id.unwrap_or_default(); - transaction.add_keycloak_group(&role_id); - debug!("Created role group {} for organization", role); - - // Add the user to the role-specific group - if let Err(e) = admin - .realm_users_with_user_id_groups_with_group_id_put(realm, user_id, &role_id) - .await - { - // If adding user fails, handle rollback via transaction manager - if let Err(rollback_err) = transaction - .handle_rollback_if_needed(admin, realm, state) - .await - { - error!("Rollback failed: {}", rollback_err); - } - - return Err(ABError::InternalServerError(format!( - "Failed to add user to role group: {}", - e - ))); - } - } - Err(e) => { - // If role group creation fails, handle rollback via transaction manager - if let Err(rollback_err) = transaction - .handle_rollback_if_needed(admin, realm, state) - .await - { - error!("Rollback failed: {}", rollback_err); - } - - return Err(ABError::InternalServerError(format!( - "Failed to create role group: {}", - e - ))); - } - } - } - - transaction.set_database_inserted(); - debug!( - "Organization {} uses pre-configured Superposition organization ID from environment.", - organisation - ); - - // Transaction is complete for Keycloak group creation - info!( - "Successfully completed transaction to create Keycloak groups for organization {}", - organisation - ); - - Ok(Organisation { - name: organisation.to_string(), - applications: vec![], - access: ROLES.iter().map(|&s| s.to_string()).collect(), - }) -} - -/// Delete an organization with robust transaction support -pub async fn delete_organisation_with_transaction( - organisation: &str, - admin: &KeycloakAdmin, - realm: &str, - // user_id: &str, - state: &web::Data, -) -> airborne_types::Result<()> { - // Create a transaction manager for this operation - let transaction = TransactionManager::new(organisation, "organization_delete"); - - debug!( - "Starting transaction to delete organization {}", - organisation - ); - - debug!("Organization {} uses pre-configured Superposition organization ID from environment. This delete operation will focus on Keycloak resources.", organisation); - - // Find and track all groups to delete - // First, get the organization parent group - let groups = match admin - .realm_groups_get( - realm, - None, - Some(true), - None, - Some(2), - Some(false), - None, - Some(organisation.to_string()), - ) - .await - { - Ok(groups) => groups, - Err(e) => { - return Err(ABError::InternalServerError(format!( - "Failed to retrieve organization groups: {}", - e - ))) - } - }; - - // Find the parent group ID - let parent_group_id = match groups - .iter() - .find(|g| g.name == Some(organisation.to_string())) - { - Some(group) => match &group.id { - Some(id) => id.clone(), - None => { - return Err(ABError::InternalServerError( - "Parent group has no ID".to_string(), - )) - } - }, - None => { - return Err(ABError::InternalServerError( - "Parent group not found".to_string(), - )) - } - }; - - // Track the parent group for potential rollback (in case we need to restore) - transaction.add_keycloak_group(&parent_group_id); - - debug!( - "Skipping local database deletion for organization {} as table is removed.", - organisation - ); - warn!("The pre-configured Superposition organization (ID from env) is not affected by this Keycloak group deletion operation."); - transaction.set_database_inserted(); // Signifies this phase is complete (no actual DB delete for org) - - // Step 3: Delete all applications associated with the organization (Concept might remain if apps are tied to Keycloak group) - // This would involve finding all applications and deleting them - // Omitted for brevity, but would be needed in a complete implementation - - // Step 4: Delete the Keycloak group (this will cascade delete all child groups) - match admin - .realm_groups_with_group_id_delete(realm, &parent_group_id) - .await - { - Ok(_) => { - debug!("Deleted organization group in Keycloak"); - } - Err(e) => { - // If Keycloak deletion fails, handle rollback (restore DB entry) - if let Err(rollback_err) = transaction - .handle_rollback_if_needed(admin, realm, state) - .await - { - error!("Rollback failed: {}", rollback_err); - } - - return Err(ABError::InternalServerError(format!( - "Failed to delete organization groups in Keycloak: {}", - e - ))); - } - } - - // Transaction is complete - info!( - "Successfully completed transaction to delete organization {}", - organisation - ); - - Ok(()) -} diff --git a/airborne_server/src/organisation/types.rs b/airborne_server/src/organisation/types.rs deleted file mode 100644 index cb2bc8a8..00000000 --- a/airborne_server/src/organisation/types.rs +++ /dev/null @@ -1,47 +0,0 @@ -use http::StatusCode; -use thiserror::Error; - -use crate::{ - impl_response_error, - types::{ABErrorCodes, AppError, HasLabel}, -}; - -/// Errors that can occur during organization operations -#[derive(Error, Debug)] -pub enum OrgError { - #[error("User not found: {0}")] - UserNotFound(String), - - #[error("Organisation not found: {0}")] - OrgNotFound(String), - - #[error("Invalid access level: {0}")] - InvalidAccessLevel(String), - - #[error("Permission denied: {0}")] - PermissionDenied(String), - - #[error("Last owner cannot be modified: {0}")] - LastOwner(String), -} - -impl AppError for OrgError { - fn code(&self) -> &'static str { - match self { - OrgError::UserNotFound(_) => ABErrorCodes::NotFound.label(), - OrgError::OrgNotFound(_) => ABErrorCodes::NotFound.label(), - OrgError::InvalidAccessLevel(_) => ABErrorCodes::Unauthorized.label(), - OrgError::PermissionDenied(_) => ABErrorCodes::Unauthorized.label(), - OrgError::LastOwner(_) => ABErrorCodes::Unauthorized.label(), - } - } - fn status_code(&self) -> StatusCode { - match self { - OrgError::UserNotFound(_) => StatusCode::NOT_FOUND, - OrgError::OrgNotFound(_) => StatusCode::NOT_FOUND, - OrgError::InvalidAccessLevel(_) | OrgError::LastOwner(_) => StatusCode::BAD_REQUEST, - OrgError::PermissionDenied(_) => StatusCode::FORBIDDEN, - } - } -} -impl_response_error!(OrgError); diff --git a/airborne_server/src/organisation/user.rs b/airborne_server/src/organisation/user.rs index 03a5e984..4469693a 100644 --- a/airborne_server/src/organisation/user.rs +++ b/airborne_server/src/organisation/user.rs @@ -12,33 +12,21 @@ // See the License for the specific language governing permissions and // limitations under the License. -mod transaction; mod types; -mod utils; use actix_web::{ get, post, - web::{self, Json}, + web::{self, Json, ReqData}, HttpMessage, HttpRequest, Scope, }; -use log::{debug, info}; +use airborne_authz_macros::authz; +use log::info; use crate::{ - middleware::auth::{ - validate_required_access, validate_user, Access, AuthResponse, ADMIN, OWNER, READ, WRITE, - }, - organisation::{types::OrgError, user::types::*}, + middleware::auth::{require_scope_name, AuthResponse}, + organisation::user::types::*, types as airborne_types, types::{ABError, AppState}, - utils::keycloak::{find_org_group, find_user_by_username, prepare_user_action}, -}; - -use self::{ - transaction::{ - add_user_with_transaction, get_user_current_role, remove_user_with_transaction, - update_user_with_transaction, - }, - utils::{check_role_hierarchy, is_last_owner, validate_access_level}, }; pub fn add_routes() -> Scope { @@ -48,168 +36,62 @@ pub fn add_routes() -> Scope { .service(organisation_update_user) .service(organisation_remove_user) .service(organisation_transfer_ownership) + .service(list_organisation_roles) + .service(list_organisation_permissions) + .service(upsert_organisation_role) } -/// Get organization context and validate user permissions -async fn get_org_context( - req: &HttpRequest, - required_level: Access, - operation: &str, -) -> airborne_types::Result<(String, AuthResponse)> { +async fn get_org_context(req: &HttpRequest) -> airborne_types::Result<(String, AuthResponse)> { let auth = req .extensions() .get::() .cloned() .ok_or_else(|| ABError::Unauthorized("Missing auth context".to_string()))?; - validate_required_access(&auth, required_level.access, operation).await?; - - let org_name = validate_user(auth.organisation.clone(), required_level)?; - + let org_name = require_scope_name(auth.organisation.clone(), "organisation")?; Ok((org_name, auth)) } -/// Find a user and extract their ID -async fn find_target_user( - admin: &keycloak::KeycloakAdmin, - realm: &str, - username: &str, -) -> airborne_types::Result { - let target_user = find_user_by_username(admin, realm, username) - .await? - .ok_or_else(|| OrgError::UserNotFound(username.to_string()))?; - - let target_user_id = target_user - .id - .as_ref() - .ok_or_else(|| ABError::InternalServerError("User has no ID".to_string()))? - .to_string(); - - let username = target_user - .username - .as_ref() - .ok_or_else(|| ABError::InternalServerError("User has no username".to_string()))? - .to_string(); - - Ok(UserContext { - user_id: target_user_id, - username, - }) -} - -/// Find an organization and extract its ID -async fn find_organization( - admin: &keycloak::KeycloakAdmin, - realm: &str, - org_name: &str, -) -> airborne_types::Result { - let org_group = find_org_group(admin, realm, org_name) - .await - .map_err(|e| ABError::InternalServerError(format!("Keycloak error: {}", e)))? - .ok_or_else(|| OrgError::OrgNotFound(org_name.to_string()))?; - - let org_group_id = org_group - .id - .as_ref() - .ok_or_else(|| ABError::InternalServerError("Group has no ID".to_string()))? - .to_string(); - - Ok(OrgContext { - org_id: org_name.to_string(), - group_id: org_group_id, - }) -} - -/// Check if the user can be modified (not the last owner) -async fn check_user_modifiable( - admin: &keycloak::KeycloakAdmin, - realm: &str, - org_group_id: &str, - target_user_id: &str, - new_role: &str, -) -> airborne_types::Result<()> { - // Only check if we're changing from owner role - if new_role != "owner" { - let is_last = is_last_owner(admin, realm, org_group_id, target_user_id) - .await - .map_err(|e| ABError::InternalServerError(e.to_string()))?; - - if is_last { - return Err(OrgError::LastOwner( - "Cannot modify the last owner. Add another owner first.".to_string(), - ) - .into()); - } - } - Ok(()) -} - +#[authz( + resource = "organisation_user", + action = "create", + org_roles = ["owner", "admin", "write"], + app_roles = [] +)] #[post("/create")] async fn organisation_add_user( req: HttpRequest, body: Json, + auth_response: ReqData, state: web::Data, ) -> airborne_types::Result> { let body = body.into_inner(); - - // Get organization context and validate requester's permissions - let (organisation, auth) = get_org_context(&req, WRITE, "add user").await?; - let requester_id = &auth.sub; - - // Prepare Keycloak admin client - let (admin, realm) = prepare_user_action(&req, state.clone()) - .await - .map_err(|e| ABError::InternalServerError(e.to_string()))?; - - // Validate access level - let (role_name, role_level) = validate_access_level(&body.access.as_str())?; - - // Additional permission check for admin/owner assignments - if role_level >= ADMIN.access { - if let Some(org_access) = &auth.organisation { - if org_access.level < ADMIN.access { - return Err(OrgError::PermissionDenied( - "Admin permission required to assign admin or owner roles".into(), - ) - .into()); - } - } else { - return Err(ABError::Forbidden("No organization access".to_string())); - } + let (organisation, auth) = get_org_context(&req).await?; + let role_name = body.access.trim(); + + // Reject service account emails — they must be managed via the service accounts API + let user_lower = body.user.trim().to_ascii_lowercase(); + if user_lower.ends_with("@service-account.airborne.juspay.in") { + return Err(ABError::BadRequest( + "Service account emails cannot be added as regular users. Use the service accounts API instead.".to_string(), + )); } - // Find target user and organization in parallel - let (target_user, org_context) = tokio::join!( - find_target_user(&admin, &realm, &body.user), - find_organization(&admin, &realm, &organisation) - ); - - let target_user = target_user?; - let org_context = org_context?; - - // Check role hierarchy - check_role_hierarchy( - &admin, - &realm, - &org_context.group_id, - requester_id, - &target_user.user_id, - ) - .await?; - - debug!( - "Adding user {} to org {} with access level {}", - body.user, organisation, role_name - ); - - // Use transaction function to add user - add_user_with_transaction(&admin, &realm, &org_context, &target_user, &role_name).await?; + state + .authz_provider + .add_organisation_user( + state.get_ref(), + &auth.sub, + &organisation, + &body.user, + role_name, + ) + .await?; info!( - "Successfully added user {} to org {} with access level {}", + "Added org user {} in org {} with role {}", body.user, organisation, role_name ); - Ok(Json(UserOperationResponse { user: body.user, success: true, @@ -217,71 +99,38 @@ async fn organisation_add_user( })) } +#[authz( + resource = "organisation_user", + action = "update", + org_roles = ["owner", "admin"], + app_roles = [] +)] #[post("/update")] async fn organisation_update_user( req: HttpRequest, body: Json, + auth_response: ReqData, state: web::Data, ) -> airborne_types::Result> { let request = body.into_inner(); - - // Get organization context and validate requester's permissions - let (org_name, auth) = get_org_context(&req, ADMIN, "update user").await?; - let requester_id = &auth.sub; - - // Prepare Keycloak admin client - let (admin, realm) = prepare_user_action(&req, state.clone()) - .await - .map_err(|e| ABError::InternalServerError(e.to_string()))?; - - // Validate the requested access level - let (role_name, _access_level) = validate_access_level(&request.access.as_str())?; - - // Find target user and organization - let target_user = find_target_user(&admin, &realm, &request.user).await?; - let org_context = find_organization(&admin, &realm, &org_name).await?; - - // Check if this is the last owner and we're trying to change their role - check_user_modifiable( - &admin, - &realm, - &org_context.group_id, - &target_user.user_id, - &role_name, - ) - .await?; - - // Check if requester has permission to modify this user (hierarchy check) - check_role_hierarchy( - &admin, - &realm, - &org_context.group_id, - requester_id, - &target_user.user_id, - ) - .await?; - - // Get the user's current role for the transaction - let current_role = - get_user_current_role(&admin, &realm, &org_context, &target_user.user_id).await?; - - // Use transaction function to update user - update_user_with_transaction( - &admin, - &realm, - &org_context, - &target_user, - &role_name, - ¤t_role, - &state, - ) - .await?; + let (org_name, auth) = get_org_context(&req).await?; + let role_name = request.access.trim(); + + state + .authz_provider + .update_organisation_user( + state.get_ref(), + &auth.sub, + &org_name, + &request.user, + role_name, + ) + .await?; info!( - "Successfully updated user {} in org {} to role {}", + "Updated org user {} in org {} to role {}", request.user, org_name, role_name ); - Ok(Json(UserOperationResponse { user: request.user, success: true, @@ -289,71 +138,28 @@ async fn organisation_update_user( })) } +#[authz( + resource = "organisation_user", + action = "delete", + org_roles = ["owner", "admin"], + app_roles = [] +)] #[post("/remove")] async fn organisation_remove_user( req: HttpRequest, body: Json, + auth_response: ReqData, state: web::Data, ) -> airborne_types::Result> { let request = body.into_inner(); + let (org_name, auth) = get_org_context(&req).await?; - // Get organization context and validate requester's permissions - let (org_name, auth) = get_org_context(&req, ADMIN, "remove user").await?; - let requester_id = &auth.sub; - - // Prepare Keycloak admin client - let (admin, realm) = prepare_user_action(&req, state.clone()) - .await - .map_err(|e| ABError::InternalServerError(e.to_string()))?; - - // Find target user and organization - let target_user = find_target_user(&admin, &realm, &request.user).await?; - let org_context = find_organization(&admin, &realm, &org_name).await?; - - // Check if this user is the last owner (can't remove them) - let is_last = is_last_owner(&admin, &realm, &org_context.group_id, &target_user.user_id) - .await - .map_err(|e| ABError::InternalServerError(e.to_string()))?; - - if is_last { - return Err(OrgError::LastOwner( - "Cannot remove the last owner from the organization".to_string(), - ) - .into()); - } - - // Check if requester has permission to modify this user (hierarchy check) - check_role_hierarchy( - &admin, - &realm, - &org_context.group_id, - requester_id, - &target_user.user_id, - ) - .await?; - - // Get user's current groups - let user_groups = admin - .realm_users_with_user_id_groups_get(&realm, &target_user.user_id, None, None, None, None) - .await - .map_err(|e| ABError::InternalServerError(format!("Failed to get user groups: {}", e)))?; - - // Use transaction function to remove user - remove_user_with_transaction( - &admin, - &realm, - &org_context, - &target_user, - &user_groups, - &state, - ) - .await?; - - info!( - "Successfully removed user {} from organization {}", - request.user, org_name - ); + state + .authz_provider + .remove_organisation_user(state.get_ref(), &auth.sub, &org_name, &request.user) + .await?; + info!("Removed org user {} from org {}", request.user, org_name); Ok(Json(UserOperationResponse { user: request.user, success: true, @@ -361,231 +167,161 @@ async fn organisation_remove_user( })) } +#[authz( + resource = "organisation_user", + action = "read", + org_roles = ["owner", "admin", "write", "read"], + app_roles = [] +)] #[get("/list")] async fn organisation_list_users( req: HttpRequest, + auth_response: ReqData, state: web::Data, ) -> airborne_types::Result> { - // Get organization context and validate requester's permissions - let (org_name, _) = get_org_context(&req, READ, "list users").await?; - - // Prepare Keycloak admin client - let (admin, realm) = prepare_user_action(&req, state) - .await - .map_err(|e| ABError::InternalServerError(e.to_string()))?; - - // Find the organization - let org_context = find_organization(&admin, &realm, &org_name).await?; - - debug!( - "Listing users for organization: {} (ID: {})", - org_name, org_context.group_id - ); - - // Get all users in the realm - let all_users = admin - .realm_users_get( - &realm, - Some(true), // briefRepresentation - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - ) - .await - .map_err(|e| ABError::InternalServerError(format!("Failed to get users: {}", e)))?; - - // Collect information about users in this organization - let mut user_infos = Vec::new(); - let org_path = format!("/{}/", org_name); - - for user in all_users { - if let Some(user_id) = user.id.as_ref() { - // Get groups for this user - let user_groups = admin - .realm_users_with_user_id_groups_get(&realm, user_id, None, None, None, None) - .await - .map_err(|e| { - ABError::InternalServerError(format!("Failed to get user groups: {}", e)) - })?; - - // Check if user is in this organization - let is_member = user_groups.iter().any(|group| { - group - .path - .as_ref() - .is_some_and(|path| path.contains(&org_path)) - }); - - if is_member { - let username = user.username.as_ref().ok_or_else(|| { - ABError::InternalServerError("User has no username".to_string()) - })?; - - // Extract roles from group paths - only organization level roles - let org_path_prefix = format!("/{}/", org_name); - let roles = user_groups - .iter() - .filter_map(|group| { - if let Some(path) = &group.path { - // Only consider direct organization-level roles - // Path should be exactly "/{org_name}/{role}" with no further nesting - if path.starts_with(&org_path_prefix) { - let role_part = &path[org_path_prefix.len()..]; - // Check if this is a direct role (no further slashes) - if !role_part.contains('/') && !role_part.is_empty() { - return Some(role_part.to_string()); - } - } - } - None - }) - .collect(); - - user_infos.push(UserInfo { - username: username.clone(), - email: user.email.clone(), - roles, - }); - } - } - } - - info!( - "Found {} users in organization {}", - user_infos.len(), - org_name - ); - - Ok(Json(ListUsersResponse { users: user_infos })) + let (org_name, _) = get_org_context(&req).await?; + let users = state + .authz_provider + .list_organisation_users(state.get_ref(), &org_name) + .await?; + + Ok(Json(ListUsersResponse { + users: users + .into_iter() + .map(|user| UserInfo { + username: user.username, + email: user.email, + roles: user.roles, + }) + .collect(), + })) } +#[authz( + resource = "organisation_user", + action = "transfer", + org_roles = ["owner"], + app_roles = [] +)] #[post("/transfer-ownership")] async fn organisation_transfer_ownership( req: HttpRequest, body: Json, + auth_response: ReqData, state: web::Data, ) -> airborne_types::Result> { - let user = body.user.clone(); - info!("[TRANSFER_OWNERSHIP] Starting ownership transfer"); - - // Get organization context and validate requester's permissions - let (org_name, auth) = get_org_context(&req, OWNER, "transfer ownership").await?; - let requester_id = &auth.sub; - - // Prepare Keycloak admin client - let (admin, realm) = prepare_user_action(&req, state.clone()) - .await - .map_err(|e| ABError::InternalServerError(e.to_string()))?; - - // Validate the requested access level - let (role_name, _access_level) = validate_access_level("owner")?; - // Find target user and organization - let target_user = find_target_user(&admin, &realm, &user).await?; - let org_context = find_organization(&admin, &realm, &org_name).await?; + let target_user = body.user.clone(); + let (org_name, auth) = get_org_context(&req).await?; - if requester_id == &target_user.user_id { - return Err(OrgError::PermissionDenied( - "Cannot transfer ownership to yourself".to_string(), - ) - .into()); - } - - // Check if requester has permission to modify this user (hierarchy check) - check_role_hierarchy( - &admin, - &realm, - &org_context.group_id, - requester_id, - &target_user.user_id, - ) - .await?; - let current_role = - get_user_current_role(&admin, &realm, &org_context, &target_user.user_id).await?; - - // Use transaction function to update user to owner - update_user_with_transaction( - &admin, - &realm, - &org_context, - &target_user, - &role_name, - ¤t_role, - &state, - ) - .await?; + state + .authz_provider + .transfer_organisation_ownership(state.get_ref(), &auth.sub, &org_name, &target_user) + .await?; - info!("[TRANSFER_OWNERSHIP] Target user promoted to owner"); - - let requester_context = UserContext { - user_id: requester_id.to_string(), - username: auth.username.clone(), - }; + Ok(Json(UserOperationResponse { + user: target_user, + success: true, + operation: "Transfer Ownership".to_string(), + })) +} - // Demote requester to admin - if this fails, rollback the target user promotion - let demote_result = update_user_with_transaction( - &admin, - &realm, - &org_context, - &requester_context, - "admin", - "owner", - &state, - ) - .await; +#[authz( + resource = "organisation_role", + action = "read", + org_roles = ["owner", "admin"], + app_roles = [] +)] +#[get("/roles/list")] +async fn list_organisation_roles( + req: HttpRequest, + auth_response: ReqData, + state: web::Data, +) -> airborne_types::Result> { + let (org_name, auth) = get_org_context(&req).await?; + let roles = state + .authz_provider + .list_role_definitions(state.get_ref(), &auth.sub, &org_name, None) + .await?; + + Ok(Json(ListRolesResponse { + roles: roles + .into_iter() + .map(|role| RoleInfo { + role: role.role, + is_system: role.is_system, + permissions: role + .permissions + .into_iter() + .map(|permission| PermissionInfo { + key: permission.key, + resource: permission.resource, + action: permission.action, + }) + .collect(), + }) + .collect(), + })) +} - if let Err(e) = demote_result { - info!( - "[TRANSFER_OWNERSHIP] Failed to demote requester, rolling back target user promotion" - ); +#[authz( + resource = "organisation_role", + action = "read", + org_roles = ["owner", "admin"], + app_roles = [] +)] +#[get("/permissions/list")] +async fn list_organisation_permissions( + req: HttpRequest, + auth_response: ReqData, + state: web::Data, +) -> airborne_types::Result> { + let (org_name, auth) = get_org_context(&req).await?; + let permissions = state + .authz_provider + .list_available_permissions(state.get_ref(), &auth.sub, &org_name, None) + .await?; + + Ok(Json(ListPermissionsResponse { + permissions: permissions + .into_iter() + .map(|permission| PermissionInfo { + key: permission.key, + resource: permission.resource, + action: permission.action, + }) + .collect(), + })) +} - // Rollback: revert target user to their original role - if let Err(rollback_err) = update_user_with_transaction( - &admin, - &realm, - &org_context, - &target_user, - ¤t_role, - &role_name, - &state, +#[authz( + resource = "organisation_role", + action = "create", + org_roles = ["owner", "admin"], + app_roles = [] +)] +#[post("/roles/upsert")] +async fn upsert_organisation_role( + req: HttpRequest, + body: Json, + auth_response: ReqData, + state: web::Data, +) -> airborne_types::Result> { + let payload = body.into_inner(); + let (org_name, auth) = get_org_context(&req).await?; + state + .authz_provider + .upsert_custom_role( + state.get_ref(), + &auth.sub, + &org_name, + None, + payload.role.trim(), + &payload.permissions, ) - .await - { - // If rollback also fails, log the error and return critical failure - log::error!( - "[TRANSFER_OWNERSHIP] CRITICAL: Rollback failed - target user stuck as owner: {}", - rollback_err - ); - return Err(ABError::InternalServerError(format!( - "Ownership transfer failed and rollback failed. Target user may be stuck as owner. Original error: {}. Rollback error: {}", - e, rollback_err - ))); - } + .await?; - info!( - "[TRANSFER_OWNERSHIP] Successfully rolled back target user to {}", - current_role - ); - return Err(ABError::InternalServerError(format!( - "Failed to demote current owner to admin: {}", - e - ))); - } - - info!("[TRANSFER_OWNERSHIP] Previous owner demoted to admin - transfer complete"); - - Ok(Json(UserOperationResponse { - user, - success: true, - operation: "Transfer Ownership".to_string(), - })) + Ok(Json(serde_json::json!({ + "success": true, + "role": payload.role.trim().to_ascii_lowercase(), + }))) } diff --git a/airborne_server/src/organisation/user/transaction.rs b/airborne_server/src/organisation/user/transaction.rs deleted file mode 100644 index 60164909..00000000 --- a/airborne_server/src/organisation/user/transaction.rs +++ /dev/null @@ -1,588 +0,0 @@ -// Copyright 2025 Juspay Technologies -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use actix_web::web; -use keycloak::KeycloakAdmin; -use log::{debug, error, info, warn}; -use serde::{Deserialize, Serialize}; - -use crate::{ - types as airborne_types, - types::{ABError, AppState}, - utils::{ - keycloak::{find_org_group, find_role_subgroup}, - transaction_manager::{record_failed_cleanup, TransactionManager}, - }, -}; - -use super::{OrgContext, UserContext}; - -/// Represents the operation being performed on a user in an organization -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum UserOperation { - Add, - Update, - Remove, -} - -/// Add a user to an organization with transaction management -pub async fn add_user_with_transaction( - admin: &KeycloakAdmin, - realm: &str, - org_context: &OrgContext, - target_user: &UserContext, - role_name: &str, - // state: &web::Data, -) -> airborne_types::Result<()> { - // Create a new transaction manager for this operation - let transaction = TransactionManager::new(&org_context.org_id, "organization_user"); - - debug!( - "Starting transaction to add user {} to org {} with role {}", - target_user.username, org_context.org_id, role_name - ); - - let org_group = find_org_group(admin, realm, &org_context.org_id) - .await - .map_err(|e| { - ABError::InternalServerError(format!("Failed to find organization group: {}", e)) - })? - .ok_or_else(|| { - ABError::InternalServerError(format!( - "Organization group {} not found", - org_context.org_id - )) - })?; - - let role_name = role_name.trim().to_ascii_lowercase(); - - let mut roles = get_additional_roles(&role_name).await.map_err(|e| { - ABError::InternalServerError(format!("Failed to get additional roles: {}", e)) - })?; - - roles.push(role_name.to_string()); - - for role_name in roles.clone() { - add_user_to_group( - admin, - realm, - &target_user.user_id, - &org_context.group_id, - &role_name, - &transaction, - ) - .await - .map_err(|e| ABError::InternalServerError(format!("Failed to add user to group: {}", e)))?; - } - - if role_name == "admin" { - info!("Let's update the subgroups"); - match admin - .realm_groups_with_group_id_children_get( - realm, - &org_context.group_id, - None, - None, - None, - org_group.sub_group_count.map(|v| v as i32), - None, - ) - .await - { - Ok(groups) => { - // Record the user groups in the transaction - let child_roles = roles - .clone() - .iter() - .filter(|role| **role != "owner") - .cloned() - .collect::>(); - // remove groups that are roles - let groups: Vec<_> = groups - .into_iter() - .filter(|g| { - if let Some(name) = &g.name { - let roles = ["admin", "write", "read", "owner"]; - !roles.contains(&name.as_str()) - } else { - true - } - }) - .collect(); - for group in groups { - for role_name in child_roles.iter() { - match group.id { - Some(ref group_id) => { - add_user_to_group( - admin, - realm, - &target_user.user_id, - group_id, - role_name, - &transaction, - ) - .await - .map_err(|e| { - ABError::InternalServerError(format!( - "Failed to add user to group: {}", - e - )) - })?; - } - None => warn!("Group has no ID, skipping"), - } - } - } - } - Err(e) => { - warn!( - "Failed to get user groups after adding user: {}. This may not be critical.", - e - ); - } - } - } - // Mark the transaction as complete since there are no database or Superposition resources involved - transaction.set_database_inserted(); - - info!( - "Successfully completed transaction to add user {} to org {} with role {}", - target_user.username, org_context.org_id, role_name - ); - - Ok(()) -} - -/// Roles come with additional roles like Owner has Admin, Write, Read -/// This function retrieves those additional roles for a given role name -async fn get_additional_roles(role_name: &str) -> airborne_types::Result> { - let additional_roles = match role_name { - "owner" => vec!["admin".to_string(), "write".to_string(), "read".to_string()], - "admin" => vec!["write".to_string(), "read".to_string()], - "write" => vec!["read".to_string()], - _ => vec![], - }; - - if additional_roles.is_empty() && role_name != "read" { - return Err(ABError::InternalServerError(format!( - "No additional roles found for role {}", - role_name - ))); - } - - Ok(additional_roles) -} - -async fn add_user_to_group( - admin: &KeycloakAdmin, - realm: &str, - user_id: &str, - group_id: &str, - role_name: &str, - transaction: &TransactionManager, -) -> airborne_types::Result<()> { - // Find the role group - let role_group = find_role_subgroup(admin, realm, group_id, role_name) - .await - .map_err(|e| ABError::InternalServerError(format!("Failed to find role group: {}", e)))? - .ok_or_else(|| { - ABError::InternalServerError(format!("Role group {} not found", role_name)) - })?; - - let role_group_id = role_group - .id - .as_ref() - .ok_or_else(|| ABError::InternalServerError("Role group has no ID".to_string()))? - .to_string(); - - // Step 1: Add user to role group - match admin - .realm_users_with_user_id_groups_with_group_id_put(realm, user_id, &role_group_id) - .await - { - Ok(_) => { - // Record this resource in the transaction - transaction.add_keycloak_resource( - "user_group_membership", - &format!("{}:{}", user_id, role_group_id), - ); - debug!("Added user {} to role group {}", user_id, role_name); - } - Err(e) => { - // If this fails, there's nothing to roll back yet - return Err(ABError::InternalServerError(format!( - "Failed to add user to role group: {}", - e - ))); - } - } - Ok(()) -} - -async fn remove_user_from_group( - admin: &KeycloakAdmin, - realm: &str, - user_id: &str, - group_id: &str, - role_name: &str, - transaction: &TransactionManager, -) -> airborne_types::Result<()> { - // Find the role group - let role_group = find_role_subgroup(admin, realm, group_id, role_name) - .await - .map_err(|e| ABError::InternalServerError(format!("Failed to find role group: {}", e)))? - .ok_or_else(|| { - ABError::InternalServerError(format!("Role group {} not found", role_name)) - })?; - - let role_group_id = role_group - .id - .as_ref() - .ok_or_else(|| ABError::InternalServerError("Role group has no ID".to_string()))? - .to_string(); - - // Step 1: Remove user to role group - match admin - .realm_users_with_user_id_groups_with_group_id_delete(realm, user_id, &role_group_id) - .await - { - Ok(_) => { - // Record this resource in the transaction - transaction.add_keycloak_resource( - "user_group_membership", - &format!("{}:{}", user_id, role_group_id), - ); - } - Err(e) => { - // If this fails, there's nothing to roll back yet - return Err(ABError::InternalServerError(format!( - "Failed to remove user from role group: {}", - e - ))); - } - } - Ok(()) -} -/// Update a user's role in an organization with transaction management -pub async fn update_user_with_transaction( - admin: &KeycloakAdmin, - realm: &str, - org_context: &OrgContext, - target_user: &UserContext, - new_role_name: &str, - current_role: &str, - _state: &web::Data, -) -> airborne_types::Result<()> { - let transaction = TransactionManager::new(&org_context.org_id, "organization_user_update"); - - let mut new_roles = get_additional_roles(new_role_name).await.map_err(|e| { - ABError::InternalServerError(format!("Failed to get additional roles: {}", e)) - })?; - new_roles.push(new_role_name.to_string()); - - let mut current_roles = get_additional_roles(current_role).await.map_err(|e| { - ABError::InternalServerError(format!("Failed to get additional roles: {}", e)) - })?; - current_roles.push(current_role.to_string()); - - let to_add: Vec<_> = new_roles - .iter() - .filter(|r| !current_roles.contains(r)) - .cloned() - .collect(); - - let to_remove: Vec<_> = current_roles - .iter() - .filter(|r| !new_roles.contains(r)) - .cloned() - .collect(); - - for role_name in &to_add { - add_user_to_group( - admin, - realm, - &target_user.user_id, - &org_context.group_id, - role_name, - &transaction, - ) - .await - .map_err(|e| { - ABError::InternalServerError(format!( - "Failed to add user to group '{}': {}", - role_name, e - )) - })?; - } - - for role_name in &to_remove { - remove_user_from_group( - admin, - realm, - &target_user.user_id, - &org_context.group_id, - role_name, - &transaction, - ) - .await - .map_err(|e| { - ABError::InternalServerError(format!( - "Failed to remove user from group '{}': {}", - role_name, e - )) - })?; - } - - let org_group = find_org_group(admin, realm, &org_context.org_id) - .await - .map_err(|e| { - ABError::InternalServerError(format!("Failed to find organization group: {}", e)) - })? - .ok_or_else(|| { - ABError::InternalServerError(format!( - "Organization group {} not found", - org_context.org_id - )) - })?; - - if new_role_name == "admin" { - match admin - .realm_groups_with_group_id_children_get( - realm, - &org_context.group_id, - None, - None, - None, - org_group.sub_group_count.map(|v| v as i32), - None, - ) - .await - { - Ok(groups) => { - // Record the user groups in the transaction - let child_roles = new_roles - .clone() - .iter() - .filter(|role| **role != "owner") - .cloned() - .collect::>(); - // remove groups that are roles - let groups: Vec<_> = groups - .into_iter() - .filter(|g| { - if let Some(name) = &g.name { - let roles = ["admin", "write", "read", "owner"]; - !roles.contains(&name.as_str()) - } else { - true - } - }) - .collect(); - for group in groups { - for role_name in child_roles.iter() { - match group.id { - Some(ref group_id) => { - add_user_to_group( - admin, - realm, - &target_user.user_id, - group_id, - role_name, - &transaction, - ) - .await - .map_err(|e| { - ABError::InternalServerError(format!( - "Failed to add user to group: {}", - e - )) - })?; - } - None => warn!("Group has no ID, skipping"), - } - } - } - } - Err(e) => { - warn!( - "Failed to get user groups after adding user: {}. This may not be critical.", - e - ); - } - } - } - - transaction.set_database_inserted(); - - Ok(()) -} -/// Remove a user from an organization with transaction management -pub async fn remove_user_with_transaction( - admin: &KeycloakAdmin, - realm: &str, - org_context: &OrgContext, - target_user: &UserContext, - user_groups: &[keycloak::types::GroupRepresentation], - state: &web::Data, -) -> airborne_types::Result<()> { - // Create a new transaction manager - let transaction = TransactionManager::new(&org_context.org_id, "organization_user_remove"); - - debug!( - "Starting transaction to remove user {} from org {}", - target_user.username, org_context.org_id - ); - - // Filter groups that belong to this organization - let org_path = format!("/{}/", org_context.org_id); - let org_groups: Vec<_> = user_groups - .iter() - .filter(|g| g.path.as_ref().is_some_and(|p| p.contains(&org_path))) - .collect(); - - // Keep track of groups we've removed the user from (for potential rollback) - let mut removed_groups = Vec::new(); - - // Remove user from all organization groups - for group in org_groups { - if let (Some(path), Some(group_id)) = (&group.path, &group.id) { - debug!( - "Removing user {} from group: {}", - target_user.username, path - ); - - match admin - .realm_users_with_user_id_groups_with_group_id_delete( - realm, - &target_user.user_id, - group_id, - ) - .await - { - Ok(_) => { - debug!("Successfully removed user from group: {}", path); - removed_groups.push(group.clone()); - transaction.add_keycloak_resource( - "user_group_removal", - &format!("{}:{}", target_user.user_id, group_id), - ); - } - Err(e) => { - warn!( - "Failed to remove user from group {}: {}. Attempting rollback...", - path, e - ); - - // Attempt to rollback by adding user back to removed groups - let mut rollback_failed = false; - for removed_group in &removed_groups { - if let Some(removed_id) = &removed_group.id { - if let Err(rollback_err) = admin - .realm_users_with_user_id_groups_with_group_id_put( - realm, - &target_user.user_id, - removed_id, - ) - .await - { - error!( - "Rollback failed for group {}: {}", - removed_group - .path - .as_ref() - .unwrap_or(&"unknown".to_string()), - rollback_err - ); - rollback_failed = true; - } - } - } - - // If rollback failed, record for future cleanup - if rollback_failed { - if let Err(record_err) = - record_failed_cleanup(state, &transaction.get_state()).await - { - error!("Failed to record cleanup job: {}", record_err); - } - } - - return Err(ABError::InternalServerError(format!( - "Failed to remove user from group {}: {}", - path, e - ))); - } - } - } - } - - // Mark transaction as complete - transaction.set_database_inserted(); - - info!( - "Successfully completed transaction to remove user {} from org {}", - target_user.username, org_context.org_id - ); - - Ok(()) -} - -/// Get a user's current role in an organization -pub async fn get_user_current_role( - admin: &KeycloakAdmin, - realm: &str, - org_context: &OrgContext, - user_id: &str, -) -> airborne_types::Result { - // Get user's groups - let user_groups = admin - .realm_users_with_user_id_groups_get(realm, user_id, None, None, None, None) - .await - .map_err(|e| ABError::InternalServerError(format!("Failed to get user groups: {}", e)))?; - - // Find role groups under this organization - let org_path = format!("/{}/", org_context.org_id); - let mut user_roles = Vec::new(); - // Find the role group the user is in - for group in user_groups { - if let Some(path) = group.path { - if path.starts_with(&org_path) && path != org_path { - let parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); - let org_parts: Vec<&str> = org_path.split('/').filter(|s| !s.is_empty()).collect(); - - // If it's exactly one level deeper than org_path, it's a direct child - if parts.len() == org_parts.len() + 1 { - if let Some(role) = parts.last() { - info!( - "[GET_USER_CURRENT_ROLE] Found user role from path {}: role={}", - path, role - ); - user_roles.push(role.to_string()); - } - } - } - } - } - let hierarchy = ["owner", "admin", "write", "read"]; - for &role in &hierarchy { - if user_roles.contains(&role.to_string()) { - return Ok(role.to_string()); - } - } - - Err(ABError::InternalServerError(format!( - "User is not a member of any role in organization {}", - org_context.org_id - ))) -} diff --git a/airborne_server/src/organisation/user/types.rs b/airborne_server/src/organisation/user/types.rs index 0f2dfbeb..80631f5c 100644 --- a/airborne_server/src/organisation/user/types.rs +++ b/airborne_server/src/organisation/user/types.rs @@ -1,26 +1,9 @@ use serde::{Deserialize, Serialize}; -#[derive(Deserialize, Debug)] -#[serde(rename_all = "lowercase")] -pub enum AccessLvl { - Admin, - Write, - Read, -} - -impl AccessLvl { - pub fn as_str(&self) -> String { - match self { - Self::Admin => "admin".to_string(), - Self::Write => "write".to_string(), - Self::Read => "read".to_string(), - } - } -} #[derive(Deserialize)] pub struct UserRequest { pub user: String, - pub access: AccessLvl, + pub access: String, } #[derive(Deserialize)] @@ -47,14 +30,32 @@ pub struct UserInfo { pub roles: Vec, } -// Helper structs +#[derive(Serialize, Clone)] +pub struct PermissionInfo { + pub key: String, + pub resource: String, + pub action: String, +} -pub struct UserContext { - pub user_id: String, - pub username: String, +#[derive(Serialize, Clone)] +pub struct RoleInfo { + pub role: String, + pub is_system: bool, + pub permissions: Vec, +} + +#[derive(Serialize)] +pub struct ListRolesResponse { + pub roles: Vec, } -pub struct OrgContext { - pub org_id: String, - pub group_id: String, +#[derive(Serialize)] +pub struct ListPermissionsResponse { + pub permissions: Vec, +} + +#[derive(Deserialize)] +pub struct UpsertRoleRequest { + pub role: String, + pub permissions: Vec, } diff --git a/airborne_server/src/organisation/user/utils.rs b/airborne_server/src/organisation/user/utils.rs deleted file mode 100644 index 6960ab48..00000000 --- a/airborne_server/src/organisation/user/utils.rs +++ /dev/null @@ -1,146 +0,0 @@ -// Copyright 2025 Juspay Technologies -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use crate::{ - middleware::auth::{ADMIN, OWNER, READ, WRITE}, - types as airborne_types, - types::ABError, - utils::keycloak::find_role_subgroup, -}; - -use super::OrgError; - -pub fn get_user_highest_level( - groups: &[keycloak::types::GroupRepresentation], - org_id: &str, -) -> Option { - let mut highest = 0; - - for group in groups { - if let Some(parent_id) = &group.parent_id { - if parent_id != org_id { - continue; - } - - // Get the role name from the group name instead of path - if let Some(role) = &group.name { - if let Some(level) = match role.as_str() { - "read" => Some(READ.access), - "write" => Some(WRITE.access), - "admin" => Some(ADMIN.access), - "owner" => Some(OWNER.access), - _ => None, - } { - highest = highest.max(level); - } - } - } - } - - if highest > 0 { - Some(highest) - } else { - None - } -} - -pub async fn check_role_hierarchy( - admin: &keycloak::KeycloakAdmin, - realm: &str, - org_group_id: &str, - requester_id: &str, - target_user_id: &str, -) -> airborne_types::Result<()> { - if requester_id == target_user_id { - return Ok(()); - } - - let (requester_groups_result, target_groups_result) = tokio::join!( - admin.realm_users_with_user_id_groups_get(realm, requester_id, None, None, None, None), - admin.realm_users_with_user_id_groups_get(realm, target_user_id, None, None, None, None) - ); - - let requester_groups = requester_groups_result.map_err(|e| { - ABError::InternalServerError(format!("Failed to get requester groups: {}", e)) - })?; - - let target_groups = target_groups_result?; - - let requester_level = - get_user_highest_level(&requester_groups, org_group_id).ok_or_else(|| { - ABError::InternalServerError("Failed to determine requester's access level".to_string()) - })?; - - let target_level = get_user_highest_level(&target_groups, org_group_id).unwrap_or(0); - - if target_level > requester_level { - return Err(OrgError::PermissionDenied( - "Cannot modify users with higher access levels".into(), - ) - .into()); - } - - Ok(()) -} - -/// Check if user is the last owner of an organization -pub async fn is_last_owner( - admin: &keycloak::KeycloakAdmin, - realm: &str, - org_group_id: &str, - user_id: &str, -) -> airborne_types::Result { - // Find owner group - let owner_group = find_role_subgroup(admin, realm, org_group_id, "owner") - .await? - .ok_or_else(|| ABError::InternalServerError("Owner group not found".to_string()))?; - - let owner_group_id = owner_group - .id - .as_ref() - .ok_or_else(|| ABError::InternalServerError("Owner group has no ID".to_string()))?; - - // Check if user is an owner - let user_groups = admin - .realm_users_with_user_id_groups_get(realm, user_id, None, None, None, None) - .await - .map_err(|e| ABError::InternalServerError(format!("Failed to get user groups: {}", e)))?; - - let is_owner = user_groups - .iter() - .any(|g| g.id.as_ref() == Some(owner_group_id)); - - if !is_owner { - return Ok(false); - } - - // Count total owners - let all_owners = admin - .realm_groups_with_group_id_members_get(realm, owner_group_id, None, None, None) - .await - .map_err(|e| ABError::InternalServerError(format!("Failed to get group members: {}", e)))?; - - Ok(all_owners.len() <= 1) -} - -/// Validate access level string -pub fn validate_access_level(access: &str) -> airborne_types::Result<(String, u8)> { - match access.to_lowercase().as_str() { - "read" => Ok(("read".to_string(), READ.access)), - "write" => Ok(("write".to_string(), WRITE.access)), - "admin" => Ok(("admin".to_string(), ADMIN.access)), - "owner" => Ok(("owner".to_string(), OWNER.access)), - _ => Err(OrgError::InvalidAccessLevel(access.to_string()).into()), - } -} diff --git a/airborne_server/src/package.rs b/airborne_server/src/package.rs index c7b00afe..8ce3f44d 100644 --- a/airborne_server/src/package.rs +++ b/airborne_server/src/package.rs @@ -24,10 +24,11 @@ use actix_web::{ web::{self, Json, Query}, Scope, }; +use airborne_authz_macros::authz; use crate::{ file::utils::parse_file_key, - middleware::auth::{validate_user, AuthResponse, ADMIN, READ, WRITE}, + middleware::auth::{require_org_and_app, AuthResponse}, types as airborne_types, types::AppState, }; @@ -46,6 +47,12 @@ pub fn add_routes() -> Scope { .service(list_packages) } +#[authz( + resource = "package", + action = "create", + org_roles = ["owner", "admin", "write"], + app_roles = ["admin", "write"] +)] #[post("")] async fn create_package( req: web::Json, @@ -53,17 +60,10 @@ async fn create_package( state: web::Data, ) -> airborne_types::Result>> { let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), WRITE) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; let pool = state.db_pool.clone(); let request = req.into_inner(); @@ -150,6 +150,12 @@ async fn create_package( ) } +#[authz( + resource = "package", + action = "read", + org_roles = ["owner", "admin", "write", "read"], + app_roles = ["admin", "write", "read"] +)] #[get("")] async fn get_package( query: Query, @@ -164,17 +170,10 @@ async fn get_package( } let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), READ) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; let pool = state.db_pool.clone(); @@ -205,6 +204,12 @@ async fn get_package( Ok(Json(utils::db_response_to_package(package))) } +#[authz( + resource = "package", + action = "read", + org_roles = ["owner", "admin", "write", "read"], + app_roles = ["admin", "write", "read"] +)] #[get("/list")] async fn list_packages( pagination_query: Query, @@ -214,17 +219,10 @@ async fn list_packages( ) -> airborne_types::Result>> { let search = package_query.search.clone(); let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), READ) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; let pool = state.db_pool.clone(); diff --git a/airborne_server/src/provider.rs b/airborne_server/src/provider.rs new file mode 100644 index 00000000..19469952 --- /dev/null +++ b/airborne_server/src/provider.rs @@ -0,0 +1,2 @@ +pub mod authn; +pub mod authz; diff --git a/airborne_server/src/provider/authn.rs b/airborne_server/src/provider/authn.rs new file mode 100644 index 00000000..af0be491 --- /dev/null +++ b/airborne_server/src/provider/authn.rs @@ -0,0 +1,722 @@ +use std::{ + collections::HashMap, + sync::{Arc, OnceLock}, + time::{Duration, Instant}, +}; + +use async_trait::async_trait; +use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, TokenData, Validation}; +use log::{debug, warn}; +use openidconnect::{ + core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata, CoreTokenResponse}, + AuthorizationCode, ClientId, ClientSecret, ConfigurationError, CsrfToken, IssuerUrl, Nonce, + OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RefreshToken, + ResourceOwnerPassword, ResourceOwnerUsername, Scope, TokenResponse as OidcTokenResponseTrait, +}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; + +use crate::{ + types as airborne_types, + types::{ABError, AppState, AuthnProviderKind, Environment}, + user::types::{LoginFailure, TokenResponse, UserCredentials, UserToken}, +}; + +pub mod auth0; +pub mod keycloak; +pub mod oidc; +pub mod okta; + +const OIDC_CACHE_TTL: Duration = Duration::from_secs(300); +const OAUTH_PKCE_STATE_TTL: Duration = Duration::from_secs(600); + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AuthnTokenClaims { + pub sub: String, + pub preferred_username: Option, + pub email: Option, + pub iss: Option, +} + +#[derive(Clone, Debug)] +pub struct OidcProviderMetadata { + pub issuer: String, +} + +#[derive(Clone, Debug)] +pub struct OAuthUrlResponse { + pub auth_url: String, + pub state: String, +} + +#[derive(Clone)] +struct CachedOidcData { + fetched_at: Instant, + provider_metadata: CoreProviderMetadata, + jwks: JsonWebKeySet, +} + +#[derive(Clone)] +struct CachedPkceData { + created_at: Instant, + code_verifier: String, +} + +#[derive(Clone, Debug, Deserialize)] +struct JsonWebKeySet { + keys: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +struct JsonWebKey { + kid: Option, + kty: String, + alg: Option, + n: Option, + e: Option, +} + +static OIDC_CACHE: OnceLock>> = OnceLock::new(); +static OAUTH_PKCE_CACHE: OnceLock>> = OnceLock::new(); +static OIDC_HTTP_CLIENT: OnceLock = OnceLock::new(); + +fn oidc_cache() -> &'static RwLock> { + OIDC_CACHE.get_or_init(|| RwLock::new(HashMap::new())) +} + +fn oauth_pkce_cache() -> &'static RwLock> { + OAUTH_PKCE_CACHE.get_or_init(|| RwLock::new(HashMap::new())) +} + +fn oidc_http_client() -> &'static Client { + OIDC_HTTP_CLIENT.get_or_init(|| { + Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("Failed to build OIDC HTTP client") + }) +} + +fn rewrite_to_external_endpoint( + endpoint: &str, + internal_issuer: &str, + external_issuer: &str, +) -> String { + let internal = internal_issuer.trim_end_matches('/'); + let external = external_issuer.trim_end_matches('/'); + if let Some(stripped_endpoint) = endpoint.strip_prefix(internal) { + format!("{}{}", external, stripped_endpoint) + } else { + endpoint.to_string() + } +} + +fn provider_metadata_to_jwks( + provider_metadata: &CoreProviderMetadata, +) -> airborne_types::Result { + let serialized = serde_json::to_value(provider_metadata.jwks()).map_err(|error| { + ABError::InternalServerError(format!("Failed to serialize OIDC JWKS: {error}")) + })?; + serde_json::from_value(serialized).map_err(|error| { + ABError::InternalServerError(format!("Failed to deserialize OIDC JWKS: {error}")) + }) +} + +fn map_configuration_error(context: &str, error: ConfigurationError) -> ABError { + ABError::InternalServerError(format!("{context}: {error}")) +} + +fn purge_expired_pkce_entries(cache: &mut HashMap) { + cache.retain(|_, value| value.created_at.elapsed() <= OAUTH_PKCE_STATE_TTL); +} + +async fn save_pkce_verifier(oauth_state: String, code_verifier: String) { + let cache = oauth_pkce_cache(); + let mut write_guard = cache.write().await; + purge_expired_pkce_entries(&mut write_guard); + write_guard.insert( + oauth_state, + CachedPkceData { + created_at: Instant::now(), + code_verifier, + }, + ); +} + +async fn consume_pkce_verifier(oauth_state: Option<&str>) -> airborne_types::Result { + let oauth_state = oauth_state + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| ABError::BadRequest("Missing OAuth state parameter".to_string()))?; + + let cache = oauth_pkce_cache(); + let mut write_guard = cache.write().await; + purge_expired_pkce_entries(&mut write_guard); + + let pkce_entry = write_guard + .remove(oauth_state) + .ok_or_else(|| ABError::Unauthorized("Invalid or expired OAuth state".to_string()))?; + + Ok(pkce_entry.code_verifier) +} + +async fn fetch_oidc_data( + env: &Environment, + force_refresh: bool, +) -> airborne_types::Result { + let cache_key = env.authn_issuer_url.trim_end_matches('/').to_string(); + let cache = oidc_cache(); + + if !force_refresh { + let read_guard = cache.read().await; + if let Some(cached) = read_guard.get(&cache_key) { + if cached.fetched_at.elapsed() < OIDC_CACHE_TTL { + return Ok(cached.clone()); + } + } + } + + let issuer_url = IssuerUrl::new(cache_key.clone()).map_err(|error| { + ABError::InternalServerError(format!("Invalid OIDC issuer URL '{cache_key}': {error}")) + })?; + let provider_metadata = CoreProviderMetadata::discover_async(issuer_url, oidc_http_client()) + .await + .map_err(|error| { + ABError::InternalServerError(format!( + "Failed to discover OIDC provider metadata: {error}" + )) + })?; + let jwks = provider_metadata_to_jwks(&provider_metadata)?; + let new_data = CachedOidcData { + fetched_at: Instant::now(), + provider_metadata, + jwks, + }; + let mut write_guard = cache.write().await; + write_guard.insert(cache_key, new_data.clone()); + Ok(new_data) +} + +pub async fn get_oidc_provider_metadata( + env: &Environment, +) -> airborne_types::Result { + let oidc_data = fetch_oidc_data(env, false).await?; + + Ok(OidcProviderMetadata { + issuer: oidc_data.provider_metadata.issuer().url().to_string(), + }) +} + +fn is_supported_algorithm(algorithm: Algorithm) -> bool { + matches!( + algorithm, + Algorithm::RS256 | Algorithm::RS384 | Algorithm::RS512 + ) +} + +fn build_validation( + algorithm: Algorithm, + metadata: &OidcProviderMetadata, + env: &Environment, +) -> Validation { + let mut validation = Validation::new(algorithm); + validation.set_audience(&[env.authn_client_id.as_str()]); + validation.set_issuer(&[metadata.issuer.as_str()]); + validation.validate_exp = true; + validation.leeway = env.authn_clock_skew_secs; + validation +} + +fn decode_with_jwks( + token: &str, + algorithm: Algorithm, + kid: Option<&str>, + metadata: &OidcProviderMetadata, + env: &Environment, + jwks: &JsonWebKeySet, +) -> airborne_types::Result> { + let mut candidates: Vec<&JsonWebKey> = jwks + .keys + .iter() + .filter(|jwk| jwk.kty.eq_ignore_ascii_case("RSA") && jwk.n.is_some() && jwk.e.is_some()) + .collect(); + + if let Some(expected_kid) = kid { + let kid_filtered: Vec<&JsonWebKey> = candidates + .iter() + .copied() + .filter(|jwk| jwk.kid.as_deref() == Some(expected_kid)) + .collect(); + if !kid_filtered.is_empty() { + candidates = kid_filtered; + } + } + + if candidates.is_empty() { + return Err(ABError::Unauthorized( + "No valid RSA keys found in OIDC JWKS".to_string(), + )); + } + + let validation = build_validation(algorithm, metadata, env); + let mut last_error = None; + + for jwk in candidates { + if let Some(jwk_alg) = jwk.alg.as_deref() { + let expected = match algorithm { + Algorithm::RS256 => "RS256", + Algorithm::RS384 => "RS384", + Algorithm::RS512 => "RS512", + _ => "", + }; + if !expected.is_empty() && !jwk_alg.eq_ignore_ascii_case(expected) { + continue; + } + } + + let key = DecodingKey::from_rsa_components( + jwk.n.as_deref().unwrap_or_default(), + jwk.e.as_deref().unwrap_or_default(), + ) + .map_err(|error| { + ABError::InternalServerError(format!( + "Failed to build decoding key from OIDC JWKS: {error}" + )) + })?; + + match decode::(token, &key, &validation) { + Ok(token_data) => return Ok(token_data), + Err(error) => { + last_error = Some(error); + } + } + } + + Err(ABError::Unauthorized(format!( + "Failed to verify OIDC token: {}", + last_error + .map(|err| err.to_string()) + .unwrap_or_else(|| "no suitable verification key found".to_string()) + ))) +} + +pub async fn verify_authn_token( + token: &str, + env: &Environment, +) -> airborne_types::Result> { + let header = decode_header(token)?; + let algorithm = header.alg; + if !is_supported_algorithm(algorithm) { + return Err(ABError::Unauthorized(format!( + "Unsupported OIDC token signing algorithm: {:?}", + algorithm + ))); + } + + let metadata = get_oidc_provider_metadata(env).await?; + let kid = header.kid.as_deref(); + let first_fetch = fetch_oidc_data(env, false).await?; + + match decode_with_jwks(token, algorithm, kid, &metadata, env, &first_fetch.jwks) { + Ok(token_data) => Ok(token_data), + Err(first_error) => { + // Retry once with a forced JWKS refresh to handle key rotation. + warn!( + "OIDC token verification failed on cached JWKS: {}", + first_error + ); + let refreshed = fetch_oidc_data(env, true).await?; + match decode_with_jwks(token, algorithm, kid, &metadata, env, &refreshed.jwks) { + Ok(token_data) => { + debug!("OIDC token verification succeeded after JWKS refresh"); + Ok(token_data) + } + Err(second_error) => Err(second_error), + } + } + } +} + +#[async_trait] +pub trait AuthNProvider: Send + Sync { + fn kind(&self) -> AuthnProviderKind; + + fn supports_password_login(&self) -> bool; + + fn supports_signup(&self) -> bool; + + fn supports_oidc_authorize(&self) -> bool { + true + } + + fn is_oidc_login_enabled(&self, _state: &AppState) -> bool { + self.supports_oidc_authorize() + } + + fn ensure_password_login_supported(&self) -> airborne_types::Result<()> { + if self.supports_password_login() { + Ok(()) + } else { + Err(ABError::BadRequest( + "Password login is not supported for configured AuthN provider".to_string(), + )) + } + } + + fn ensure_signup_supported(&self) -> airborne_types::Result<()> { + if self.supports_signup() { + Ok(()) + } else { + Err(ABError::BadRequest( + "User registration is not supported for configured AuthN provider".to_string(), + )) + } + } + + fn ensure_oidc_login_enabled(&self, state: &AppState) -> airborne_types::Result<()> { + if self.is_oidc_login_enabled(state) { + Ok(()) + } else { + Err(ABError::BadRequest( + "OIDC login is not supported for configured AuthN provider".to_string(), + )) + } + } + + async fn get_oauth_url( + &self, + state: &AppState, + offline: bool, + _idp_hint: Option<&str>, + ) -> airborne_types::Result { + build_oauth_url_common(state, offline, &[], &["email", "profile"]).await + } + + async fn exchange_code_for_token( + &self, + state: &AppState, + code: &str, + oauth_state: Option<&str>, + ) -> airborne_types::Result { + exchange_code_for_token_common(state, code, oauth_state).await + } + + async fn login_with_password( + &self, + state: &AppState, + credentials: &UserCredentials, + ) -> airborne_types::Result { + self.ensure_password_login_supported()?; + password_login_common(state, credentials, None).await + } + + async fn login_with_password_for_pat( + &self, + state: &AppState, + credentials: &UserCredentials, + ) -> airborne_types::Result { + self.login_with_password(state, credentials).await + } + + async fn signup_with_password( + &self, + _state: &AppState, + _credentials: &UserCredentials, + ) -> airborne_types::Result { + self.ensure_signup_supported()?; + Err(ABError::BadRequest( + "User registration is not supported for configured AuthN provider".to_string(), + )) + } + + async fn refresh_access_token( + &self, + state: &AppState, + refresh_token: &str, + ) -> airborne_types::Result { + refresh_access_token_common(state, refresh_token).await + } + + async fn verify_access_token( + &self, + state: &AppState, + access_token: &str, + ) -> airborne_types::Result> { + verify_authn_token(access_token, &state.env).await + } + + fn supports_service_accounts(&self) -> bool { + false + } + + async fn create_service_account_user( + &self, + _state: &AppState, + _username: &str, + _email: &str, + _password: &str, + ) -> airborne_types::Result { + Err(ABError::BadRequest( + "Service accounts are not supported for configured AuthN provider".to_string(), + )) + } + + async fn delete_user(&self, _state: &AppState, _username: &str) -> airborne_types::Result<()> { + Err(ABError::BadRequest( + "User deletion is not supported for configured AuthN provider".to_string(), + )) + } +} + +pub fn build_authn_provider(kind: AuthnProviderKind) -> Arc { + match kind { + AuthnProviderKind::Keycloak => Arc::new(keycloak::KeycloakAuthNProvider), + AuthnProviderKind::Oidc => Arc::new(oidc::OidcAuthNProvider), + AuthnProviderKind::Okta => Arc::new(okta::OktaAuthNProvider), + AuthnProviderKind::Auth0 => Arc::new(auth0::Auth0AuthNProvider), + } +} + +fn redirect_uri(state: &AppState) -> String { + format!("{}/oauth/callback", state.env.public_url) +} + +fn oidc_redirect_url(state: &AppState) -> airborne_types::Result { + RedirectUrl::new(redirect_uri(state)) + .map_err(|error| ABError::InternalServerError(format!("Invalid redirect URI: {error}"))) +} + +fn oauth_token_type(token_response: &CoreTokenResponse) -> String { + token_response.token_type().as_ref().to_string() +} + +fn oauth_expires_in(token_response: &CoreTokenResponse) -> i64 { + token_response + .expires_in() + .map(|duration| duration.as_secs().min(i64::MAX as u64) as i64) + .unwrap_or_default() +} + +fn to_public_token_response(token_response: &CoreTokenResponse) -> TokenResponse { + TokenResponse { + access_token: token_response.access_token().secret().to_string(), + token_type: oauth_token_type(token_response), + expires_in: oauth_expires_in(token_response), + refresh_token: token_response + .refresh_token() + .map(|token| token.secret().to_string()), + refresh_expires_in: None, + id_token: token_response + .id_token() + .map(|id_token| id_token.to_string()), + } +} + +fn to_user_token(token_response: &CoreTokenResponse) -> airborne_types::Result { + let refresh_token = token_response + .refresh_token() + .ok_or_else(|| { + ABError::InternalServerError( + "Authentication response missing refresh token".to_string(), + ) + })? + .secret() + .to_string(); + + Ok(UserToken { + access_token: token_response.access_token().secret().to_string(), + token_type: oauth_token_type(token_response), + expires_in: oauth_expires_in(token_response), + refresh_token, + // refresh_expires_in is provider-specific and not part of core OAuth2/OIDC token response. + refresh_expires_in: 0, + }) +} + +pub async fn build_oauth_url_common( + state: &AppState, + offline: bool, + extra_query_params: &[(&str, &str)], + additional_scopes: &[&str], +) -> airborne_types::Result { + let oidc_data = fetch_oidc_data(&state.env, false).await?; + let client = CoreClient::from_provider_metadata( + oidc_data.provider_metadata, + ClientId::new(state.env.authn_client_id.clone()), + Some(ClientSecret::new(state.env.authn_client_secret.clone())), + ) + .set_redirect_uri(oidc_redirect_url(state)?); + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + + let mut authorization_request = client.authorize_url( + CoreAuthenticationFlow::AuthorizationCode, + CsrfToken::new_random, + Nonce::new_random, + ); + authorization_request = authorization_request.set_pkce_challenge(pkce_challenge); + + for scope in additional_scopes { + authorization_request = authorization_request.add_scope(Scope::new((*scope).to_string())); + } + + if offline { + authorization_request = + authorization_request.add_scope(Scope::new("offline_access".to_string())); + } + for (key, value) in extra_query_params { + authorization_request = + authorization_request.add_extra_param((*key).to_string(), (*value).to_string()); + } + let (auth_url, csrf_state, _nonce) = authorization_request.url(); + let external_auth_url = rewrite_to_external_endpoint( + auth_url.as_str(), + &state.env.authn_issuer_url, + &state.env.authn_external_issuer_url, + ); + let oauth_state = csrf_state.secret().to_string(); + save_pkce_verifier(oauth_state.clone(), pkce_verifier.secret().to_string()).await; + + Ok(OAuthUrlResponse { + auth_url: external_auth_url, + state: oauth_state, + }) +} + +pub async fn exchange_code_for_token_common( + state: &AppState, + code: &str, + oauth_state: Option<&str>, +) -> airborne_types::Result { + let pkce_verifier = consume_pkce_verifier(oauth_state).await?; + let oidc_data = fetch_oidc_data(&state.env, false).await?; + let client = CoreClient::from_provider_metadata( + oidc_data.provider_metadata, + ClientId::new(state.env.authn_client_id.clone()), + Some(ClientSecret::new(state.env.authn_client_secret.clone())), + ) + .set_redirect_uri(oidc_redirect_url(state)?); + + let token_response = client + .exchange_code(AuthorizationCode::new(code.to_string())) + .map_err(|error| { + map_configuration_error( + "OIDC client is missing token endpoint for code exchange", + error, + ) + })? + .set_pkce_verifier(PkceCodeVerifier::new(pkce_verifier)) + .request_async(oidc_http_client()) + .await + .map_err(|error| ABError::Unauthorized(format!("Token exchange failed: {error}")))?; + + Ok(to_public_token_response(&token_response)) +} + +pub async fn password_login_common( + state: &AppState, + credentials: &UserCredentials, + scope: Option<&str>, +) -> airborne_types::Result { + let oidc_data = fetch_oidc_data(&state.env, false).await?; + let client = CoreClient::from_provider_metadata( + oidc_data.provider_metadata, + ClientId::new(state.env.authn_client_id.clone()), + Some(ClientSecret::new(state.env.authn_client_secret.clone())), + ) + .set_redirect_uri(oidc_redirect_url(state)?); + let username = ResourceOwnerUsername::new(credentials.name.clone()); + let password = ResourceOwnerPassword::new(credentials.password.clone()); + let mut token_request = client + .exchange_password(&username, &password) + .map_err(|error| { + map_configuration_error( + "OIDC client is missing token endpoint for password login", + error, + ) + })?; + + if let Some(scope) = scope { + for single_scope in scope.split_whitespace() { + token_request = token_request.add_scope(Scope::new(single_scope.to_string())); + } + } + + let token_response = token_request + .request_async(oidc_http_client()) + .await + .map_err(|error| { + let error_text = error.to_string(); + let login_err = LoginFailure { + error: "Authentication failed".to_string(), + error_description: error_text, + }; + ABError::Unauthorized(login_err.error_description) + })?; + + to_user_token(&token_response) +} + +pub async fn refresh_access_token_common( + state: &AppState, + refresh_token: &str, +) -> airborne_types::Result { + let oidc_data = fetch_oidc_data(&state.env, false).await?; + let client = CoreClient::from_provider_metadata( + oidc_data.provider_metadata, + ClientId::new(state.env.authn_client_id.clone()), + Some(ClientSecret::new(state.env.authn_client_secret.clone())), + ) + .set_redirect_uri(oidc_redirect_url(state)?); + let refresh = RefreshToken::new(refresh_token.to_string()); + + let token_response = client + .exchange_refresh_token(&refresh) + .map_err(|error| { + map_configuration_error( + "OIDC client is missing token endpoint for refresh token flow", + error, + ) + })? + .request_async(oidc_http_client()) + .await + .map_err(|error| { + let error_text = error.to_string(); + let login_err = LoginFailure { + error: "Unknown error".to_string(), + error_description: error_text, + }; + ABError::Unauthorized(login_err.error_description) + })?; + + to_user_token(&token_response) +} + +#[cfg(test)] +mod tests { + use super::rewrite_to_external_endpoint; + + #[test] + fn rewrites_authorization_endpoint_to_external_issuer() { + let rewritten = rewrite_to_external_endpoint( + "http://internal-idp/realms/demo/protocol/openid-connect/auth", + "http://internal-idp/realms/demo", + "https://public-idp.example.com/realms/demo", + ); + assert_eq!( + rewritten, + "https://public-idp.example.com/realms/demo/protocol/openid-connect/auth" + ); + } + + #[test] + fn keeps_authorization_endpoint_when_prefix_does_not_match() { + let endpoint = "https://accounts.example.com/oauth2/v1/authorize"; + let rewritten = rewrite_to_external_endpoint( + endpoint, + "http://internal-idp/realms/demo", + "https://public-idp.example.com/realms/demo", + ); + assert_eq!(rewritten, endpoint); + } +} diff --git a/airborne_server/src/provider/authn/auth0.rs b/airborne_server/src/provider/authn/auth0.rs new file mode 100644 index 00000000..1470af9b --- /dev/null +++ b/airborne_server/src/provider/authn/auth0.rs @@ -0,0 +1,20 @@ +use async_trait::async_trait; + +use crate::{provider::authn::AuthNProvider, types::AuthnProviderKind}; + +pub struct Auth0AuthNProvider; + +#[async_trait] +impl AuthNProvider for Auth0AuthNProvider { + fn kind(&self) -> AuthnProviderKind { + AuthnProviderKind::Auth0 + } + + fn supports_password_login(&self) -> bool { + false + } + + fn supports_signup(&self) -> bool { + false + } +} diff --git a/airborne_server/src/provider/authn/keycloak.rs b/airborne_server/src/provider/authn/keycloak.rs new file mode 100644 index 00000000..4cfb1434 --- /dev/null +++ b/airborne_server/src/provider/authn/keycloak.rs @@ -0,0 +1,266 @@ +use ::keycloak::{ + types::{CredentialRepresentation, UserRepresentation}, + KeycloakAdmin, +}; +use async_trait::async_trait; +use reqwest::Client; + +use crate::{ + provider::authn::{ + build_oauth_url_common, password_login_common, AuthNProvider, OAuthUrlResponse, + }, + types as airborne_types, + types::{ABError, AppState, AuthnProviderKind}, + user::types::{UserCredentials, UserToken}, + utils::keycloak::{find_user_by_username, get_token}, +}; + +pub struct KeycloakAuthNProvider; + +fn required_signup_field( + value: &Option, + field_name: &str, +) -> airborne_types::Result { + let trimmed = value + .as_ref() + .map(|item| item.trim()) + .filter(|item| !item.is_empty()) + .ok_or_else(|| ABError::BadRequest(format!("`{field_name}` is required for signup")))?; + Ok(trimmed.to_string()) +} + +#[async_trait] +impl AuthNProvider for KeycloakAuthNProvider { + fn kind(&self) -> AuthnProviderKind { + AuthnProviderKind::Keycloak + } + + fn supports_password_login(&self) -> bool { + true + } + + fn supports_signup(&self) -> bool { + true + } + + fn is_oidc_login_enabled(&self, state: &AppState) -> bool { + !state.env.enabled_oidc_idps.is_empty() + } + + fn ensure_oidc_login_enabled(&self, state: &AppState) -> airborne_types::Result<()> { + if self.is_oidc_login_enabled(state) { + Ok(()) + } else { + Err(ABError::BadRequest( + "No OIDC identity providers are configured".to_string(), + )) + } + } + + async fn get_oauth_url( + &self, + state: &AppState, + offline: bool, + idp_hint: Option<&str>, + ) -> airborne_types::Result { + self.ensure_oidc_login_enabled(state)?; + let selected_idp = if let Some(requested_idp) = idp_hint { + let normalized = requested_idp.trim().to_ascii_lowercase(); + if normalized.is_empty() { + return Err(ABError::BadRequest( + "OIDC identity provider cannot be empty".to_string(), + )); + } + if !state + .env + .enabled_oidc_idps + .iter() + .any(|configured| configured.eq_ignore_ascii_case(&normalized)) + { + return Err(ABError::BadRequest(format!( + "Unsupported OIDC identity provider: {}", + requested_idp + ))); + } + normalized + } else { + state + .env + .enabled_oidc_idps + .first() + .cloned() + .ok_or_else(|| { + ABError::BadRequest("No OIDC identity providers are configured".to_string()) + })? + }; + + let extra_query_params = [("kc_idp_hint", selected_idp.as_str())]; + build_oauth_url_common(state, offline, &extra_query_params, &[]).await + } + + async fn login_with_password( + &self, + state: &AppState, + credentials: &UserCredentials, + ) -> airborne_types::Result { + password_login_common(state, credentials, None).await + } + + async fn login_with_password_for_pat( + &self, + state: &AppState, + credentials: &UserCredentials, + ) -> airborne_types::Result { + password_login_common(state, credentials, Some("offline_access")).await + } + + async fn signup_with_password( + &self, + state: &AppState, + credentials: &UserCredentials, + ) -> airborne_types::Result { + self.ensure_signup_supported()?; + let first_name = required_signup_field(&credentials.first_name, "first_name")?; + let last_name = required_signup_field(&credentials.last_name, "last_name")?; + let email = required_signup_field(&credentials.email, "email")?; + + if state.env.keycloak_url.trim().is_empty() { + return Err(ABError::InternalServerError( + "AUTH_ADMIN_ISSUER must be configured for Keycloak signup".to_string(), + )); + } + if state.env.realm.trim().is_empty() { + return Err(ABError::InternalServerError( + "Unable to derive Keycloak realm from AUTH_ADMIN_ISSUER".to_string(), + )); + } + + let admin_token = get_token(state.env.clone(), Client::new()) + .await + .map_err(|_| ABError::InternalServerError("Failed to get admin token".to_string()))?; + let admin = KeycloakAdmin::new(&state.env.keycloak_url, admin_token, Client::new()); + + if find_user_by_username(&admin, &state.env.realm, &credentials.name) + .await? + .is_some() + { + return Err(ABError::BadRequest("User already Exists".to_string())); + } + + let user = UserRepresentation { + username: Some(credentials.name.clone()), + first_name: Some(first_name), + last_name: Some(last_name), + email: Some(email), + email_verified: Some(false), + credentials: Some(vec![CredentialRepresentation { + value: Some(credentials.password.clone()), + temporary: Some(false), + type_: Some("password".to_string()), + ..Default::default() + }]), + enabled: Some(true), + ..Default::default() + }; + admin.realm_users_post(&state.env.realm, user).await?; + + self.login_with_password(state, credentials).await + } + + fn supports_service_accounts(&self) -> bool { + true + } + + async fn create_service_account_user( + &self, + state: &AppState, + username: &str, + email: &str, + password: &str, + ) -> airborne_types::Result { + if state.env.keycloak_url.trim().is_empty() { + return Err(ABError::InternalServerError( + "AUTH_ADMIN_ISSUER must be configured for service account creation".to_string(), + )); + } + if state.env.realm.trim().is_empty() { + return Err(ABError::InternalServerError( + "Unable to derive Keycloak realm from AUTH_ADMIN_ISSUER".to_string(), + )); + } + + let admin_token = get_token(state.env.clone(), Client::new()) + .await + .map_err(|_| ABError::InternalServerError("Failed to get admin token".to_string()))?; + let admin = KeycloakAdmin::new(&state.env.keycloak_url, admin_token, Client::new()); + + if find_user_by_username(&admin, &state.env.realm, username) + .await? + .is_some() + { + return Err(ABError::BadRequest( + "Service account user already exists".to_string(), + )); + } + + let user = UserRepresentation { + username: Some(username.to_string()), + first_name: Some("Service".to_string()), + last_name: Some("Account".to_string()), + email: Some(email.to_string()), + email_verified: Some(true), + credentials: Some(vec![CredentialRepresentation { + value: Some(password.to_string()), + temporary: Some(false), + type_: Some("password".to_string()), + ..Default::default() + }]), + enabled: Some(true), + ..Default::default() + }; + admin.realm_users_post(&state.env.realm, user).await?; + + // Login with offline_access to get a long-lived refresh token + let credentials = UserCredentials { + name: username.to_string(), + password: password.to_string(), + first_name: None, + last_name: None, + email: None, + }; + password_login_common(state, &credentials, Some("offline_access")).await + } + + async fn delete_user(&self, state: &AppState, username: &str) -> airborne_types::Result<()> { + if state.env.keycloak_url.trim().is_empty() || state.env.realm.trim().is_empty() { + return Err(ABError::InternalServerError( + "Keycloak admin configuration required for user deletion".to_string(), + )); + } + + let admin_token = get_token(state.env.clone(), Client::new()) + .await + .map_err(|_| ABError::InternalServerError("Failed to get admin token".to_string()))?; + let admin = KeycloakAdmin::new(&state.env.keycloak_url, admin_token, Client::new()); + + let user = find_user_by_username(&admin, &state.env.realm, username) + .await? + .ok_or_else(|| ABError::NotFound("User not found in identity provider".to_string()))?; + + let user_id = user.id.ok_or_else(|| { + ABError::InternalServerError("User has no ID in identity provider".to_string()) + })?; + + admin + .realm_users_with_user_id_delete(&state.env.realm, &user_id) + .await + .map_err(|e| { + ABError::InternalServerError(format!( + "Failed to delete user from identity provider: {}", + e + )) + })?; + + Ok(()) + } +} diff --git a/airborne_server/src/provider/authn/oidc.rs b/airborne_server/src/provider/authn/oidc.rs new file mode 100644 index 00000000..b4059d2e --- /dev/null +++ b/airborne_server/src/provider/authn/oidc.rs @@ -0,0 +1,20 @@ +use async_trait::async_trait; + +use crate::{provider::authn::AuthNProvider, types::AuthnProviderKind}; + +pub struct OidcAuthNProvider; + +#[async_trait] +impl AuthNProvider for OidcAuthNProvider { + fn kind(&self) -> AuthnProviderKind { + AuthnProviderKind::Oidc + } + + fn supports_password_login(&self) -> bool { + false + } + + fn supports_signup(&self) -> bool { + false + } +} diff --git a/airborne_server/src/provider/authn/okta.rs b/airborne_server/src/provider/authn/okta.rs new file mode 100644 index 00000000..89665d07 --- /dev/null +++ b/airborne_server/src/provider/authn/okta.rs @@ -0,0 +1,20 @@ +use async_trait::async_trait; + +use crate::{provider::authn::AuthNProvider, types::AuthnProviderKind}; + +pub struct OktaAuthNProvider; + +#[async_trait] +impl AuthNProvider for OktaAuthNProvider { + fn kind(&self) -> AuthnProviderKind { + AuthnProviderKind::Okta + } + + fn supports_password_login(&self) -> bool { + false + } + + fn supports_signup(&self) -> bool { + false + } +} diff --git a/airborne_server/src/provider/authz.rs b/airborne_server/src/provider/authz.rs new file mode 100644 index 00000000..eabe8cce --- /dev/null +++ b/airborne_server/src/provider/authz.rs @@ -0,0 +1,309 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use diesel::{r2d2::ConnectionManager, PgConnection}; +use r2d2::Pool; + +use crate::{ + middleware::auth::AccessLevel, + provider::authn::AuthnTokenClaims, + types as airborne_types, + types::{ABError, AppState, AuthzProviderKind}, +}; + +pub mod casbin; +pub mod migration; +pub mod permission; + +const MAX_AUTHZ_BATCH_PREALLOC: usize = 1024; + +#[derive(Clone, Debug)] +pub struct ApplicationAccessSummary { + pub organisation: String, + pub application: String, + pub access: Vec, +} + +#[derive(Clone, Debug)] +pub struct OrganisationAccessSummary { + pub name: String, + pub access: Vec, + pub applications: Vec, +} + +#[derive(Clone, Debug)] +pub struct UserAccessSummary { + pub subject: String, + pub is_super_admin: bool, + pub organisations: Vec, +} + +#[derive(Clone, Debug)] +pub struct AuthzUserInfo { + pub username: String, + pub email: Option, + pub roles: Vec, +} + +#[derive(Clone, Debug)] +pub struct AuthzAccessContext { + pub organisation: Option, + pub application: Option, + pub is_super_admin: bool, +} + +#[derive(Clone, Debug)] +pub struct AuthzPermissionAttribute { + pub key: String, + pub resource: String, + pub action: String, +} + +#[derive(Clone, Debug)] +pub struct AuthzRoleDefinition { + pub role: String, + pub is_system: bool, + pub permissions: Vec, +} + +#[derive(Clone, Debug)] +pub struct AuthzPermissionCheck { + pub organisation: String, + pub application: Option, + pub resource: String, + pub action: String, +} + +#[async_trait] +pub trait AuthZProvider: Send + Sync { + fn kind(&self) -> AuthzProviderKind; + + async fn bootstrap(&self, _state: &AppState) -> airborne_types::Result<()> { + Ok(()) + } + + fn subject_from_claims(&self, claims: &AuthnTokenClaims) -> airborne_types::Result { + claims + .email + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_ascii_lowercase()) + .ok_or_else(|| { + ABError::Unauthorized( + "Email claim is required for authorization subject mapping".to_string(), + ) + }) + } + + fn display_name_from_claims(&self, claims: &AuthnTokenClaims) -> String { + claims + .email + .clone() + .or_else(|| claims.preferred_username.clone()) + .unwrap_or_else(|| claims.sub.clone()) + } + + async fn access_for_request( + &self, + state: &AppState, + subject: &str, + organisation: Option<&str>, + application: Option<&str>, + ) -> airborne_types::Result; + + async fn get_user_access_summary( + &self, + state: &AppState, + subject: &str, + ) -> airborne_types::Result; + + async fn organisation_exists( + &self, + state: &AppState, + organisation: &str, + ) -> airborne_types::Result; + + async fn create_organisation( + &self, + state: &AppState, + organisation: &str, + owner_subject: &str, + ) -> airborne_types::Result<()>; + + async fn delete_organisation( + &self, + state: &AppState, + organisation: &str, + ) -> airborne_types::Result<()>; + + async fn create_application( + &self, + state: &AppState, + organisation: &str, + application: &str, + creator_subject: &str, + ) -> airborne_types::Result<()>; + + async fn list_organisation_users( + &self, + state: &AppState, + organisation: &str, + ) -> airborne_types::Result>; + + async fn add_organisation_user( + &self, + state: &AppState, + actor_subject: &str, + organisation: &str, + target_subject: &str, + role: &str, + ) -> airborne_types::Result<()>; + + async fn update_organisation_user( + &self, + state: &AppState, + actor_subject: &str, + organisation: &str, + target_subject: &str, + role: &str, + ) -> airborne_types::Result<()>; + + async fn remove_organisation_user( + &self, + state: &AppState, + actor_subject: &str, + organisation: &str, + target_subject: &str, + ) -> airborne_types::Result<()>; + + async fn transfer_organisation_ownership( + &self, + state: &AppState, + actor_subject: &str, + organisation: &str, + target_subject: &str, + ) -> airborne_types::Result<()>; + + async fn list_application_users( + &self, + state: &AppState, + organisation: &str, + application: &str, + ) -> airborne_types::Result>; + + async fn add_application_user( + &self, + state: &AppState, + actor_subject: &str, + organisation: &str, + application: &str, + target_subject: &str, + role: &str, + ) -> airborne_types::Result<()>; + + async fn update_application_user( + &self, + state: &AppState, + actor_subject: &str, + organisation: &str, + application: &str, + target_subject: &str, + role: &str, + ) -> airborne_types::Result<()>; + + async fn remove_application_user( + &self, + state: &AppState, + actor_subject: &str, + organisation: &str, + application: &str, + target_subject: &str, + ) -> airborne_types::Result<()>; + + async fn list_role_definitions( + &self, + _state: &AppState, + _actor_subject: &str, + _organisation: &str, + _application: Option<&str>, + ) -> airborne_types::Result> { + Ok(Vec::new()) + } + + async fn list_available_permissions( + &self, + _state: &AppState, + _actor_subject: &str, + _organisation: &str, + _application: Option<&str>, + ) -> airborne_types::Result> { + Ok(Vec::new()) + } + + async fn upsert_custom_role( + &self, + _state: &AppState, + _actor_subject: &str, + _organisation: &str, + _application: Option<&str>, + _role: &str, + _permissions: &[String], + ) -> airborne_types::Result<()> { + Ok(()) + } + + async fn enforce_permission( + &self, + _state: &AppState, + _subject: &str, + _organisation: &str, + _application: Option<&str>, + _resource: &str, + _action: &str, + ) -> airborne_types::Result { + Ok(false) + } + + async fn enforce_permissions_batch( + &self, + state: &AppState, + subject: &str, + checks: &[AuthzPermissionCheck], + ) -> airborne_types::Result> { + let mut decisions = Vec::with_capacity(checks.len().min(MAX_AUTHZ_BATCH_PREALLOC)); + for check in checks { + let allowed = self + .enforce_permission( + state, + subject, + &check.organisation, + check.application.as_deref(), + &check.resource, + &check.action, + ) + .await?; + decisions.push(allowed); + } + Ok(decisions) + } +} + +pub async fn build_authz_provider( + kind: AuthzProviderKind, + bootstrap_super_admins: Vec, + db_pool: Pool>, + casbin_auto_load_secs: Option, +) -> airborne_types::Result> { + match kind { + AuthzProviderKind::Casbin => { + let provider = casbin::CasbinAuthzProvider::new( + bootstrap_super_admins, + db_pool, + casbin_auto_load_secs, + ) + .await?; + Ok(Arc::new(provider)) + } + } +} diff --git a/airborne_server/src/provider/authz/casbin.rs b/airborne_server/src/provider/authz/casbin.rs new file mode 100644 index 00000000..455bea2b --- /dev/null +++ b/airborne_server/src/provider/authz/casbin.rs @@ -0,0 +1,2323 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::Arc, + time::Duration, +}; + +use async_trait::async_trait; +use casbin::{CoreApi, DefaultModel, Enforcer, MgmtApi}; +use diesel::{ + pg::PgConnection, + prelude::*, + r2d2::{ConnectionManager, Pool}, + upsert::excluded, +}; +use diesel_adapter::DieselAdapter; +use log::{debug, info, warn}; +use tokio::{sync::RwLock, time::sleep}; + +use crate::{ + middleware::auth::{AccessLevel, ADMIN, OWNER, READ, WRITE}, + provider::authz::{ + permission::{scoped_permission, EndpointPermissionBinding}, + ApplicationAccessSummary, AuthZProvider, AuthzAccessContext, AuthzPermissionAttribute, + AuthzPermissionCheck, AuthzRoleDefinition, AuthzUserInfo, OrganisationAccessSummary, + UserAccessSummary, + }, + run_blocking, types as airborne_types, + types::{ABError, AppState, AuthzProviderKind}, + utils::db::{ + models::{AuthzMembershipEntry, AuthzRoleBindingEntry, NewAuthzMembershipEntry}, + schema::hyperotaserver::{authz_memberships, authz_role_bindings}, + }, +}; + +const POLICY_SCOPE_SYSTEM: &str = "system"; +const POLICY_SCOPE_ORG: &str = "org"; +const POLICY_SCOPE_APP: &str = "app"; + +const ROLE_SUPER_ADMIN: &str = "super_admin"; +const ROLE_OWNER: &str = "owner"; +const ROLE_ADMIN: &str = "admin"; +const ROLE_WRITE: &str = "write"; +const ROLE_READ: &str = "read"; +const MAX_AUTHZ_BATCH_PREALLOC: usize = 1024; + +const CASBIN_MODEL: &str = r#" +[request_definition] +r = sub, scope, org, app, act + +[policy_definition] +p = sub, scope, org, app, act + +[role_definition] +g = _, _ + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = r.sub == p.sub && (p.scope == "system" || r.scope == p.scope) && (p.org == "*" || r.org == p.org) && (p.app == "*" || r.app == p.app) && (r.act == p.act || g(p.act, r.act)) +"#; + +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub struct PolicyEntry { + pub subject: String, + pub scope: String, + pub organisation: String, + pub application: String, + pub action: String, +} + +impl PolicyEntry { + fn as_vec(&self) -> Vec { + vec![ + self.subject.clone(), + self.scope.clone(), + self.organisation.clone(), + self.application.clone(), + self.action.clone(), + ] + } +} + +pub struct CasbinAuthzProvider { + enforcer: Arc>, + bootstrap_super_admins: Vec, + db_pool: Pool>, +} + +impl CasbinAuthzProvider { + pub async fn new( + bootstrap_super_admins: Vec, + db_pool: Pool>, + auto_load_secs: Option, + ) -> airborne_types::Result { + let model = DefaultModel::from_str(CASBIN_MODEL) + .await + .map_err(|error| { + ABError::InternalServerError(format!("Failed to load Casbin model: {error}")) + })?; + let adapter = DieselAdapter::with_pool(db_pool.clone()).map_err(|error| { + ABError::InternalServerError(format!( + "Failed to create Casbin PostgreSQL adapter: {error}" + )) + })?; + let mut enforcer = Enforcer::new(model, adapter).await.map_err(|error| { + ABError::InternalServerError(format!("Failed to initialize Casbin enforcer: {error}")) + })?; + enforcer.enable_auto_save(true); + ensure_role_hierarchy(&mut enforcer).await?; + + let enforcer = Arc::new(RwLock::new(enforcer)); + if let Some(reload_secs) = auto_load_secs.filter(|value| *value > 0) { + let enforcer_for_task = Arc::clone(&enforcer); + tokio::spawn(async move { + let interval = Duration::from_secs(reload_secs); + loop { + sleep(interval).await; + let mut guard = enforcer_for_task.write().await; + if let Err(error) = guard.load_policy().await { + warn!("Failed to reload Casbin policy from DB: {}", error); + } + } + }); + } + + Ok(Self { + enforcer, + bootstrap_super_admins: normalize_subjects(bootstrap_super_admins)?, + db_pool, + }) + } + + pub async fn import_policy_entries( + &self, + entries: &[PolicyEntry], + apply: bool, + ) -> airborne_types::Result { + let mut collapsed: BTreeMap<(String, String, String, String), String> = BTreeMap::new(); + for entry in entries { + let normalized = normalize_policy_entry(entry)?; + let key = ( + normalized.subject, + normalized.scope, + normalized.organisation, + normalized.application, + ); + let existing = collapsed.get(&key).cloned(); + if existing + .as_ref() + .and_then(|role| role_level(role)) + .unwrap_or_default() + < role_level(&normalized.action).unwrap_or_default() + { + collapsed.insert(key, normalized.action); + } + } + let normalized = collapsed + .into_iter() + .map( + |((subject, scope, organisation, application), action)| PolicyEntry { + subject, + scope, + organisation, + application, + action, + }, + ) + .collect::>(); + if !apply { + return Ok(normalized.len()); + } + let mut guard = self.enforcer.write().await; + let mut applied = 0usize; + for entry in normalized { + let added = guard.add_policy(entry.as_vec()).await.map_err(|error| { + ABError::InternalServerError(format!("Failed to import policy: {error}")) + })?; + if added { + applied += 1; + } + } + Ok(applied) + } + + async fn has_super_admin_role(&self, subject: &str) -> airborne_types::Result { + let normalized_subject = normalize_subject(subject)?; + let guard = self.enforcer.read().await; + guard + .enforce(( + normalized_subject.as_str(), + POLICY_SCOPE_SYSTEM, + "*", + "*", + ROLE_SUPER_ADMIN, + )) + .map_err(|error| { + ABError::InternalServerError(format!( + "Failed to evaluate super-admin policy: {error}" + )) + }) + } + + async fn highest_org_access_level( + &self, + subject: &str, + organisation: &str, + ) -> airborne_types::Result> { + self.highest_membership_level(subject, POLICY_SCOPE_ORG, organisation, None) + .await + } + + async fn highest_app_access_level( + &self, + subject: &str, + organisation: &str, + application: &str, + ) -> airborne_types::Result> { + self.highest_membership_level(subject, POLICY_SCOPE_APP, organisation, Some(application)) + .await + } + + async fn list_org_apps(&self, organisation: &str) -> Vec { + let policies = self + .list_memberships(POLICY_SCOPE_APP, organisation, None) + .await + .unwrap_or_default(); + let mut apps = BTreeSet::new(); + for policy in policies { + if !policy.application.is_empty() { + apps.insert(policy.application); + } + } + apps.into_iter().collect() + } + + async fn list_org_admin_owner_subjects(&self, organisation: &str) -> Vec { + let policies = self + .list_memberships(POLICY_SCOPE_ORG, organisation, None) + .await + .unwrap_or_default(); + let mut subjects = BTreeSet::new(); + for policy in policies { + if canonical_system_role(&policy.role_key).as_deref() == Some(ROLE_ADMIN) + || canonical_system_role(&policy.role_key).as_deref() == Some(ROLE_OWNER) + { + subjects.insert(policy.subject); + } + } + subjects.into_iter().collect() + } + + async fn upsert_membership( + &self, + subject: &str, + scope: &str, + organisation: &str, + application: &str, + role_key: &str, + ) -> airborne_types::Result<()> { + let normalized_subject = normalize_subject(subject)?; + let normalized_role = role_key.trim().to_ascii_lowercase(); + let scope = scope.to_string(); + let organisation = organisation.to_string(); + let application = application.to_string(); + let role_level = i32::from(role_level(&normalized_role).unwrap_or_default()); + let pool = self.db_pool.clone(); + run_blocking!({ + let mut conn = pool.get()?; + diesel::insert_into(authz_memberships::table) + .values(NewAuthzMembershipEntry { + subject: normalized_subject.clone(), + scope: scope.clone(), + organisation: organisation.clone(), + application: application.clone(), + role_key: normalized_role.clone(), + role_level, + }) + .on_conflict(( + authz_memberships::subject, + authz_memberships::scope, + authz_memberships::organisation, + authz_memberships::application, + )) + .do_update() + .set(( + authz_memberships::role_key.eq(excluded(authz_memberships::role_key)), + authz_memberships::role_level.eq(excluded(authz_memberships::role_level)), + authz_memberships::updated_at.eq(diesel::dsl::now), + )) + .execute(&mut conn)?; + Ok(()) + })?; + Ok(()) + } + + async fn remove_membership( + &self, + subject: &str, + scope: &str, + organisation: &str, + application: &str, + ) -> airborne_types::Result<()> { + let normalized_subject = normalize_subject(subject)?; + let scope = scope.to_string(); + let organisation = organisation.to_string(); + let application = application.to_string(); + let pool = self.db_pool.clone(); + run_blocking!({ + let mut conn = pool.get()?; + diesel::delete( + authz_memberships::table + .filter(authz_memberships::subject.eq(&normalized_subject)) + .filter(authz_memberships::scope.eq(&scope)) + .filter(authz_memberships::organisation.eq(&organisation)) + .filter(authz_memberships::application.eq(&application)), + ) + .execute(&mut conn)?; + Ok(()) + })?; + Ok(()) + } + + async fn remove_memberships_for_subject_org( + &self, + subject: &str, + organisation: &str, + ) -> airborne_types::Result<()> { + let normalized_subject = normalize_subject(subject)?; + let organisation = organisation.to_string(); + let pool = self.db_pool.clone(); + run_blocking!({ + let mut conn = pool.get()?; + diesel::delete( + authz_memberships::table + .filter(authz_memberships::subject.eq(&normalized_subject)) + .filter(authz_memberships::organisation.eq(&organisation)), + ) + .execute(&mut conn)?; + Ok(()) + })?; + Ok(()) + } + + async fn remove_memberships_for_org(&self, organisation: &str) -> airborne_types::Result<()> { + let organisation = organisation.to_string(); + let pool = self.db_pool.clone(); + run_blocking!({ + let mut conn = pool.get()?; + diesel::delete( + authz_memberships::table.filter(authz_memberships::organisation.eq(&organisation)), + ) + .execute(&mut conn)?; + Ok(()) + })?; + Ok(()) + } + + async fn highest_membership_level( + &self, + subject: &str, + scope: &str, + organisation: &str, + application: Option<&str>, + ) -> airborne_types::Result> { + let normalized_subject = normalize_subject(subject)?; + let scope = scope.to_string(); + let organisation = organisation.to_string(); + let application = application.unwrap_or("*").to_string(); + let pool = self.db_pool.clone(); + let level = run_blocking!({ + let mut conn = pool.get()?; + let result = authz_memberships::table + .filter(authz_memberships::subject.eq(&normalized_subject)) + .filter(authz_memberships::scope.eq(&scope)) + .filter(authz_memberships::organisation.eq(&organisation)) + .filter(authz_memberships::application.eq(&application)) + .select(authz_memberships::role_level) + .first::(&mut conn) + .optional()?; + Ok(result) + })?; + Ok(level.map(|value| value as u8)) + } + + async fn highest_role_for_membership( + &self, + subject: &str, + scope: &str, + organisation: &str, + application: Option<&str>, + ) -> airborne_types::Result> { + let normalized_subject = normalize_subject(subject)?; + let scope = scope.to_string(); + let organisation = organisation.to_string(); + let application = application.unwrap_or("*").to_string(); + let pool = self.db_pool.clone(); + let role = run_blocking!({ + let mut conn = pool.get()?; + let result = authz_memberships::table + .filter(authz_memberships::subject.eq(&normalized_subject)) + .filter(authz_memberships::scope.eq(&scope)) + .filter(authz_memberships::organisation.eq(&organisation)) + .filter(authz_memberships::application.eq(&application)) + .select(authz_memberships::role_key) + .first::(&mut conn) + .optional()?; + Ok(result) + })?; + Ok(role) + } + + async fn list_memberships( + &self, + scope: &str, + organisation: &str, + application: Option<&str>, + ) -> airborne_types::Result> { + let scope = scope.to_string(); + let organisation = organisation.to_string(); + let application = application.unwrap_or("*").to_string(); + let pool = self.db_pool.clone(); + run_blocking!({ + let mut conn = pool.get()?; + let mut query = authz_memberships::table + .filter(authz_memberships::scope.eq(&scope)) + .filter(authz_memberships::organisation.eq(&organisation)) + .into_boxed(); + if application != "*" { + query = query.filter(authz_memberships::application.eq(&application)); + } + let rows = query + .select(AuthzMembershipEntry::as_select()) + .load(&mut conn)?; + Ok(rows) + }) + } + + async fn list_subject_memberships( + &self, + subject: &str, + ) -> airborne_types::Result> { + let normalized_subject = normalize_subject(subject)?; + let pool = self.db_pool.clone(); + run_blocking!({ + let mut conn = pool.get()?; + let rows = authz_memberships::table + .filter(authz_memberships::subject.eq(normalized_subject)) + .select(AuthzMembershipEntry::as_select()) + .load(&mut conn)?; + Ok(rows) + }) + } + + async fn seed_endpoint_permission_bindings(&self) -> airborne_types::Result<()> { + let mut inventory_bindings = BTreeSet::new(); + for binding in inventory::iter:: { + let _ = ( + binding.method, + binding.path, + binding.allow_app, + binding.allow_org, + ); + for role in binding.org_roles { + inventory_bindings.insert(( + POLICY_SCOPE_ORG.to_string(), + role.trim().to_ascii_lowercase(), + binding.resource.to_string(), + binding.action.to_string(), + )); + } + for role in binding.app_roles { + inventory_bindings.insert(( + POLICY_SCOPE_APP.to_string(), + role.trim().to_ascii_lowercase(), + binding.resource.to_string(), + binding.action.to_string(), + )); + } + } + + let pool = self.db_pool.clone(); + let inventory_bindings_vec = inventory_bindings.iter().cloned().collect::>(); + let role_bindings = run_blocking!({ + let mut conn = pool.get()?; + for (scope, role_key, resource, action) in &inventory_bindings_vec { + diesel::insert_into(authz_role_bindings::table) + .values(( + authz_role_bindings::scope.eq(scope), + authz_role_bindings::role_key.eq(role_key), + authz_role_bindings::resource.eq(resource), + authz_role_bindings::action.eq(action), + )) + .on_conflict_do_nothing() + .execute(&mut conn)?; + } + let rows = authz_role_bindings::table + .select(AuthzRoleBindingEntry::as_select()) + .load::(&mut conn)?; + Ok(rows) + })?; + + let mut guard = self.enforcer.write().await; + for binding in role_bindings { + let permission = scoped_permission(&binding.scope, &binding.resource, &binding.action); + let _ = guard + .add_grouping_policy(vec![binding.role_key, permission]) + .await + .map_err(|error| { + ABError::InternalServerError(format!( + "Failed to persist role-permission grouping policy: {error}" + )) + })?; + } + Ok(()) + } + + async fn refresh_membership_cache_from_casbin(&self) -> airborne_types::Result<()> { + let policies = { + let guard = self.enforcer.read().await; + guard.get_filtered_policy(1, vec![]) + }; + + let mut rows: Vec = Vec::new(); + for policy in policies { + if policy.len() < 5 { + continue; + } + let scope = policy[1].clone(); + if scope != POLICY_SCOPE_ORG && scope != POLICY_SCOPE_APP { + continue; + } + rows.push(NewAuthzMembershipEntry { + subject: policy[0].clone(), + scope, + organisation: policy[2].clone(), + application: policy[3].clone(), + role_key: policy[4].clone(), + role_level: i32::from(role_level(&policy[4]).unwrap_or_default()), + }); + } + + let pool = self.db_pool.clone(); + run_blocking!({ + let mut conn = pool.get()?; + diesel::delete(authz_memberships::table).execute(&mut conn)?; + for row in &rows { + diesel::insert_into(authz_memberships::table) + .values(row) + .on_conflict_do_nothing() + .execute(&mut conn)?; + } + Ok(()) + })?; + Ok(()) + } + + async fn organisation_user_role( + &self, + subject: &str, + organisation: &str, + ) -> airborne_types::Result> { + self.highest_role_for_membership(subject, POLICY_SCOPE_ORG, organisation, None) + .await + } + + async fn application_user_role( + &self, + subject: &str, + organisation: &str, + application: &str, + ) -> airborne_types::Result> { + self.highest_role_for_membership(subject, POLICY_SCOPE_APP, organisation, Some(application)) + .await + } + + async fn ensure_organisation_exists(&self, organisation: &str) -> airborne_types::Result<()> { + let exists = self.organisation_exists_inner(organisation).await?; + if exists { + Ok(()) + } else { + Err(ABError::NotFound(format!( + "Organisation not found: {}", + organisation + ))) + } + } + + async fn remove_policies_for_filter( + &self, + field_index: usize, + field_values: Vec, + ) -> airborne_types::Result { + let mut guard = self.enforcer.write().await; + Self::remove_policies_for_filter_in_guard(&mut guard, field_index, field_values).await + } + + async fn remove_policies_for_filter_in_guard( + guard: &mut Enforcer, + field_index: usize, + field_values: Vec, + ) -> airborne_types::Result { + let existing = guard.get_filtered_policy(field_index, field_values); + if existing.is_empty() { + return Ok(0); + } + + // Remove by exact filtered rule so duplicate rows in backing DB are deleted in one call. + // remove_policies() is strict (expects exactly one row per rule) and can rollback when duplicates exist. + let unique_existing = existing.into_iter().collect::>(); + let existing_count = unique_existing.len(); + for rule in unique_existing { + guard + .remove_filtered_policy(0, rule) + .await + .map_err(|error| { + ABError::InternalServerError(format!( + "Failed to remove existing policies: {error}" + )) + })?; + } + + Ok(existing_count) + } + + async fn ensure_application_exists( + &self, + organisation: &str, + application: &str, + ) -> airborne_types::Result<()> { + let exists = !self + .list_memberships(POLICY_SCOPE_APP, organisation, Some(application)) + .await? + .is_empty(); + if !exists { + Err(ABError::NotFound(format!( + "Application not found: {}", + application + ))) + } else { + Ok(()) + } + } + + async fn organisation_exists_inner(&self, organisation: &str) -> airborne_types::Result { + let org_policies = self + .list_memberships(POLICY_SCOPE_ORG, organisation, None) + .await?; + if !org_policies.is_empty() { + return Ok(true); + } + let app_policies = self + .list_memberships(POLICY_SCOPE_APP, organisation, None) + .await?; + Ok(!app_policies.is_empty()) + } + + async fn set_org_role( + &self, + subject: &str, + organisation: &str, + role: &str, + ) -> airborne_types::Result<()> { + let normalized_subject = normalize_subject(subject)?; + let normalized_role = validate_org_role(role)?; + let mut guard = self.enforcer.write().await; + Self::remove_policies_for_filter_in_guard( + &mut guard, + 0, + vec![ + normalized_subject.clone(), + POLICY_SCOPE_ORG.to_string(), + organisation.to_string(), + ], + ) + .await?; + let _ = guard + .add_policy( + PolicyEntry { + subject: normalized_subject.clone(), + scope: POLICY_SCOPE_ORG.to_string(), + organisation: organisation.to_string(), + application: "*".to_string(), + action: normalized_role.clone(), + } + .as_vec(), + ) + .await + .map_err(|error| { + ABError::InternalServerError(format!( + "Failed to add organization role policy: {error}" + )) + })?; + self.upsert_membership( + &normalized_subject, + POLICY_SCOPE_ORG, + organisation, + "*", + &normalized_role, + ) + .await?; + Ok(()) + } + + async fn set_application_role( + &self, + subject: &str, + organisation: &str, + application: &str, + role: &str, + ) -> airborne_types::Result<()> { + let normalized_subject = normalize_subject(subject)?; + let normalized_role = validate_app_role(role)?; + let mut guard = self.enforcer.write().await; + Self::remove_policies_for_filter_in_guard( + &mut guard, + 0, + vec![ + normalized_subject.clone(), + POLICY_SCOPE_APP.to_string(), + organisation.to_string(), + application.to_string(), + ], + ) + .await?; + let _ = guard + .add_policy( + PolicyEntry { + subject: normalized_subject.clone(), + scope: POLICY_SCOPE_APP.to_string(), + organisation: organisation.to_string(), + application: application.to_string(), + action: normalized_role.clone(), + } + .as_vec(), + ) + .await + .map_err(|error| { + ABError::InternalServerError(format!( + "Failed to add application role policy: {error}" + )) + })?; + self.upsert_membership( + &normalized_subject, + POLICY_SCOPE_APP, + organisation, + application, + &normalized_role, + ) + .await?; + Ok(()) + } + + async fn grant_application_role_if_missing( + &self, + subject: &str, + organisation: &str, + application: &str, + role: &str, + ) -> airborne_types::Result<()> { + let normalized_subject = normalize_subject(subject)?; + let normalized_role = validate_app_role(role)?; + let current_level = self + .highest_app_access_level(&normalized_subject, organisation, application) + .await? + .unwrap_or_default(); + let desired_level = role_level(&normalized_role).unwrap_or_default(); + if current_level < desired_level { + self.set_application_role( + &normalized_subject, + organisation, + application, + &normalized_role, + ) + .await?; + } + Ok(()) + } + + async fn grant_admin_on_all_org_apps( + &self, + subject: &str, + organisation: &str, + ) -> airborne_types::Result<()> { + for app in self.list_org_apps(organisation).await { + self.grant_application_role_if_missing(subject, organisation, &app, ROLE_ADMIN) + .await?; + } + Ok(()) + } + + async fn remove_organisation_membership( + &self, + subject: &str, + organisation: &str, + ) -> airborne_types::Result<()> { + let normalized_subject = normalize_subject(subject)?; + self.remove_policies_for_filter( + 0, + vec![ + normalized_subject.clone(), + POLICY_SCOPE_ORG.to_string(), + organisation.to_string(), + ], + ) + .await?; + self.remove_policies_for_filter( + 0, + vec![ + normalized_subject.clone(), + POLICY_SCOPE_APP.to_string(), + organisation.to_string(), + ], + ) + .await?; + self.remove_memberships_for_subject_org(&normalized_subject, organisation) + .await?; + Ok(()) + } + + async fn ensure_requester_can_modify_org_member( + &self, + actor_subject: &str, + organisation: &str, + target_subject: &str, + ) -> airborne_types::Result<()> { + if self.has_super_admin_role(actor_subject).await? { + return Ok(()); + } + let actor_level = self + .highest_org_access_level(actor_subject, organisation) + .await? + .unwrap_or_default(); + let target_level = self + .highest_org_access_level(target_subject, organisation) + .await? + .unwrap_or_default(); + + if actor_level == 0 { + return Err(ABError::Forbidden("No organisation access".to_string())); + } + if actor_subject != target_subject && target_level > actor_level { + return Err(ABError::Forbidden( + "Cannot modify users with higher access levels".to_string(), + )); + } + Ok(()) + } + + async fn ensure_requester_can_modify_app_member( + &self, + actor_subject: &str, + organisation: &str, + application: &str, + target_subject: &str, + ) -> airborne_types::Result<()> { + if self.has_super_admin_role(actor_subject).await? { + return Ok(()); + } + let actor_level = self + .highest_app_access_level(actor_subject, organisation, application) + .await? + .unwrap_or_default(); + let target_level = self + .highest_app_access_level(target_subject, organisation, application) + .await? + .unwrap_or_default(); + + if actor_level == 0 { + return Err(ABError::Forbidden("No application access".to_string())); + } + if actor_subject != target_subject && target_level > actor_level { + return Err(ABError::Forbidden( + "Cannot modify users with higher access levels".to_string(), + )); + } + Ok(()) + } + + async fn org_owner_count(&self, organisation: &str) -> usize { + let policies = self + .list_memberships(POLICY_SCOPE_ORG, organisation, None) + .await + .unwrap_or_default(); + let mut owners = BTreeSet::new(); + for policy in policies { + if canonical_system_role(&policy.role_key).as_deref() == Some(ROLE_OWNER) { + owners.insert(policy.subject); + } + } + owners.len() + } + + async fn app_admin_count(&self, organisation: &str, application: &str) -> usize { + let policies = self + .list_memberships(POLICY_SCOPE_APP, organisation, Some(application)) + .await + .unwrap_or_default(); + let mut admins = BTreeSet::new(); + for policy in policies { + if canonical_system_role(&policy.role_key).as_deref() == Some(ROLE_ADMIN) { + admins.insert(policy.subject); + } + } + admins.len() + } + + async fn list_role_bindings( + &self, + scope: &str, + ) -> airborne_types::Result> { + let scope = scope.to_string(); + let pool = self.db_pool.clone(); + run_blocking!({ + let mut conn = pool.get()?; + let rows = authz_role_bindings::table + .filter(authz_role_bindings::scope.eq(scope)) + .select(AuthzRoleBindingEntry::as_select()) + .load::(&mut conn)?; + Ok(rows) + }) + } + + async fn ensure_actor_can_manage_org_roles( + &self, + actor_subject: &str, + organisation: &str, + ) -> airborne_types::Result<()> { + if self.has_super_admin_role(actor_subject).await? { + return Ok(()); + } + let actor_level = self + .highest_org_access_level(actor_subject, organisation) + .await? + .unwrap_or_default(); + if actor_level >= ADMIN.access { + Ok(()) + } else { + Err(ABError::Forbidden( + "Only organisation admin or owner can manage organisation roles".to_string(), + )) + } + } + + async fn ensure_actor_can_manage_app_roles( + &self, + actor_subject: &str, + organisation: &str, + application: &str, + ) -> airborne_types::Result<()> { + if self.has_super_admin_role(actor_subject).await? { + return Ok(()); + } + let org_level = self + .highest_org_access_level(actor_subject, organisation) + .await? + .unwrap_or_default(); + if org_level >= ADMIN.access { + return Ok(()); + } + let app_level = self + .highest_app_access_level(actor_subject, organisation, application) + .await? + .unwrap_or_default(); + if app_level >= ADMIN.access { + Ok(()) + } else { + Err(ABError::Forbidden( + "Only application admin (or organisation admin/owner) can manage application roles" + .to_string(), + )) + } + } + + async fn can_manage_role_permissions( + &self, + actor_subject: &str, + organisation: &str, + application: Option<&str>, + resource: &str, + is_super_admin: bool, + ) -> airborne_types::Result { + if is_super_admin { + return Ok(true); + } + + let org_level = self + .highest_org_access_level(actor_subject, organisation) + .await? + .unwrap_or_default(); + if org_level >= ADMIN.access { + return Ok(true); + } + + if resource == "application_role" { + if let Some(app_name) = application { + let app_level = self + .highest_app_access_level(actor_subject, organisation, app_name) + .await? + .unwrap_or_default(); + return Ok(app_level >= ADMIN.access); + } + } + + Ok(false) + } + + async fn upsert_role_bindings( + &self, + scope: &str, + role_key: &str, + permissions: &[AuthzPermissionAttribute], + ) -> airborne_types::Result<()> { + let scope_name = scope.to_string(); + let role_name = role_key.to_string(); + let permissions_for_db = permissions.to_vec(); + let scope_name_for_db = scope_name.clone(); + let role_name_for_db = role_name.clone(); + let pool = self.db_pool.clone(); + run_blocking!({ + let mut conn = pool.get()?; + diesel::delete( + authz_role_bindings::table + .filter(authz_role_bindings::scope.eq(&scope_name_for_db)) + .filter(authz_role_bindings::role_key.eq(&role_name_for_db)), + ) + .execute(&mut conn)?; + + for permission in &permissions_for_db { + diesel::insert_into(authz_role_bindings::table) + .values(( + authz_role_bindings::scope.eq(&scope_name_for_db), + authz_role_bindings::role_key.eq(&role_name_for_db), + authz_role_bindings::resource.eq(&permission.resource), + authz_role_bindings::action.eq(&permission.action), + )) + .execute(&mut conn)?; + } + Ok(()) + })?; + + // Keep in-memory Casbin grouping policies in sync immediately. + let mut guard = self.enforcer.write().await; + let existing = guard.get_filtered_named_grouping_policy("g", 0, vec![role_name.clone()]); + for row in existing { + let _ = guard.remove_grouping_policy(row).await.map_err(|error| { + ABError::InternalServerError(format!( + "Failed to remove existing role-permission grouping policy: {error}" + )) + })?; + } + + for permission in permissions { + let key = scoped_permission(&scope_name, &permission.resource, &permission.action); + let _ = guard + .add_grouping_policy(vec![role_name.clone(), key]) + .await + .map_err(|error| { + ABError::InternalServerError(format!( + "Failed to persist role-permission grouping policy: {error}" + )) + })?; + } + + Ok(()) + } +} + +#[async_trait] +impl AuthZProvider for CasbinAuthzProvider { + fn kind(&self) -> AuthzProviderKind { + AuthzProviderKind::Casbin + } + + async fn bootstrap(&self, _state: &AppState) -> airborne_types::Result<()> { + { + let mut guard = self.enforcer.write().await; + ensure_role_hierarchy(&mut guard).await?; + } + if !self.bootstrap_super_admins.is_empty() { + let mut guard = self.enforcer.write().await; + for subject in &self.bootstrap_super_admins { + let entry = PolicyEntry { + subject: subject.clone(), + scope: POLICY_SCOPE_SYSTEM.to_string(), + organisation: "*".to_string(), + application: "*".to_string(), + action: ROLE_SUPER_ADMIN.to_string(), + }; + let added = guard.add_policy(entry.as_vec()).await.map_err(|error| { + ABError::InternalServerError(format!( + "Failed to persist bootstrap super-admin policy: {error}" + )) + })?; + if added { + info!("Bootstrapped AuthZ super-admin subject: {}", subject); + } + } + } + self.seed_endpoint_permission_bindings().await?; + self.refresh_membership_cache_from_casbin().await?; + Ok(()) + } + + async fn access_for_request( + &self, + _state: &AppState, + subject: &str, + organisation: Option<&str>, + application: Option<&str>, + ) -> airborne_types::Result { + let normalized_subject = normalize_subject(subject)?; + let is_super_admin = self.has_super_admin_role(&normalized_subject).await?; + + let mut org_access = None; + let mut app_access = None; + + if let Some(org_name) = organisation { + if is_super_admin { + org_access = Some(AccessLevel { + name: org_name.to_string(), + level: OWNER.access, + }); + } else if let Some(level) = self + .highest_org_access_level(&normalized_subject, org_name) + .await? + { + org_access = Some(AccessLevel { + name: org_name.to_string(), + level, + }); + } + } + + if let (Some(org_name), Some(app_name)) = (organisation, application) { + if is_super_admin { + app_access = Some(AccessLevel { + name: app_name.to_string(), + level: OWNER.access, + }); + } else if let Some(level) = self + .highest_app_access_level(&normalized_subject, org_name, app_name) + .await? + { + app_access = Some(AccessLevel { + name: app_name.to_string(), + level, + }); + } + } + + Ok(AuthzAccessContext { + organisation: org_access, + application: app_access, + is_super_admin, + }) + } + + async fn get_user_access_summary( + &self, + _state: &AppState, + subject: &str, + ) -> airborne_types::Result { + let normalized_subject = normalize_subject(subject)?; + let policies = self.list_subject_memberships(&normalized_subject).await?; + + let mut org_roles: BTreeMap> = BTreeMap::new(); + let mut app_roles: BTreeMap<(String, String), BTreeSet> = BTreeMap::new(); + + for policy in policies { + match policy.scope.as_str() { + POLICY_SCOPE_ORG => { + org_roles + .entry(policy.organisation) + .or_default() + .insert(policy.role_key); + } + POLICY_SCOPE_APP => { + app_roles + .entry((policy.organisation, policy.application)) + .or_default() + .insert(policy.role_key); + } + _ => {} + } + } + + let is_super_admin = self.has_super_admin_role(&normalized_subject).await?; + + let mut organisations = Vec::new(); + for (org_name, roles) in org_roles { + let mut applications = Vec::new(); + for ((app_org, app_name), app_role_set) in &app_roles { + if app_org == &org_name { + let app_access = highest_role_from_set(app_role_set) + .map(|role| app_role_display_expansion(&role)) + .unwrap_or_default(); + applications.push(ApplicationAccessSummary { + organisation: app_org.clone(), + application: app_name.clone(), + access: app_access, + }); + } + } + applications.sort_by(|left, right| left.application.cmp(&right.application)); + + let org_access = highest_role_from_set(&roles) + .map(|role| org_role_display_expansion(&role)) + .unwrap_or_default(); + + organisations.push(OrganisationAccessSummary { + name: org_name, + access: org_access, + applications, + }); + } + + Ok(UserAccessSummary { + subject: normalized_subject, + is_super_admin, + organisations, + }) + } + + async fn organisation_exists( + &self, + _state: &AppState, + organisation: &str, + ) -> airborne_types::Result { + self.organisation_exists_inner(organisation).await + } + + async fn create_organisation( + &self, + _state: &AppState, + organisation: &str, + owner_subject: &str, + ) -> airborne_types::Result<()> { + if self.organisation_exists_inner(organisation).await? { + return Err(ABError::BadRequest( + "Organisation name is taken".to_string(), + )); + } + self.set_org_role(owner_subject, organisation, ROLE_OWNER) + .await + } + + async fn delete_organisation( + &self, + _state: &AppState, + organisation: &str, + ) -> airborne_types::Result<()> { + self.ensure_organisation_exists(organisation).await?; + self.remove_policies_for_filter( + 1, + vec![POLICY_SCOPE_ORG.to_string(), organisation.to_string()], + ) + .await?; + self.remove_policies_for_filter( + 1, + vec![POLICY_SCOPE_APP.to_string(), organisation.to_string()], + ) + .await?; + self.remove_memberships_for_org(organisation).await?; + Ok(()) + } + + async fn create_application( + &self, + _state: &AppState, + organisation: &str, + application: &str, + creator_subject: &str, + ) -> airborne_types::Result<()> { + self.ensure_organisation_exists(organisation).await?; + { + let guard = self.enforcer.read().await; + let existing = guard.get_filtered_policy( + 1, + vec![ + POLICY_SCOPE_APP.to_string(), + organisation.to_string(), + application.to_string(), + ], + ); + if !existing.is_empty() { + return Err(ABError::BadRequest(format!( + "Application '{}' already exists in organisation '{}'", + application, organisation + ))); + } + } + self.set_application_role(creator_subject, organisation, application, ROLE_ADMIN) + .await?; + for subject in self.list_org_admin_owner_subjects(organisation).await { + self.set_application_role(&subject, organisation, application, ROLE_ADMIN) + .await?; + } + Ok(()) + } + + async fn list_organisation_users( + &self, + _state: &AppState, + organisation: &str, + ) -> airborne_types::Result> { + let policies = self + .list_memberships(POLICY_SCOPE_ORG, organisation, None) + .await?; + + let mut users: BTreeMap> = BTreeMap::new(); + for policy in policies { + users + .entry(policy.subject) + .or_default() + .insert(policy.role_key); + } + + let mut response = Vec::new(); + for (subject, role_set) in users { + let roles = highest_role_from_set(&role_set) + .map(|role| org_role_display_expansion(&role)) + .unwrap_or_default(); + response.push(AuthzUserInfo { + username: subject.clone(), + email: Some(subject), + roles, + }); + } + Ok(response) + } + + async fn add_organisation_user( + &self, + _state: &AppState, + actor_subject: &str, + organisation: &str, + target_subject: &str, + role: &str, + ) -> airborne_types::Result<()> { + let normalized_actor = normalize_subject(actor_subject)?; + let normalized_target = normalize_subject(target_subject)?; + let normalized_role = validate_org_role(role)?; + self.ensure_organisation_exists(organisation).await?; + self.ensure_requester_can_modify_org_member( + &normalized_actor, + organisation, + &normalized_target, + ) + .await?; + + let actor_is_super_admin = self.has_super_admin_role(&normalized_actor).await?; + if !actor_is_super_admin { + let actor_level = self + .highest_org_access_level(&normalized_actor, organisation) + .await? + .unwrap_or_default(); + if canonical_system_role(&normalized_role).is_none() && actor_level < ADMIN.access { + return Err(ABError::Forbidden( + "Only organisation admin or owner can assign custom organisation roles" + .to_string(), + )); + } + let requested_level = role_level(&normalized_role).unwrap_or_default(); + if requested_level > actor_level { + return Err(ABError::Forbidden( + "Cannot assign a role higher than your own organisation access level" + .to_string(), + )); + } + } + + self.set_org_role(&normalized_target, organisation, &normalized_role) + .await?; + + if matches!(normalized_role.as_str(), ROLE_ADMIN | ROLE_OWNER) { + self.grant_admin_on_all_org_apps(&normalized_target, organisation) + .await?; + } + + Ok(()) + } + + async fn update_organisation_user( + &self, + _state: &AppState, + actor_subject: &str, + organisation: &str, + target_subject: &str, + role: &str, + ) -> airborne_types::Result<()> { + let normalized_actor = normalize_subject(actor_subject)?; + let normalized_target = normalize_subject(target_subject)?; + let normalized_role = validate_org_role(role)?; + self.ensure_organisation_exists(organisation).await?; + self.ensure_requester_can_modify_org_member( + &normalized_actor, + organisation, + &normalized_target, + ) + .await?; + + let actor_is_super_admin = self.has_super_admin_role(&normalized_actor).await?; + if !actor_is_super_admin { + let actor_level = self + .highest_org_access_level(&normalized_actor, organisation) + .await? + .unwrap_or_default(); + if canonical_system_role(&normalized_role).is_none() && actor_level < ADMIN.access { + return Err(ABError::Forbidden( + "Only organisation admin or owner can assign custom organisation roles" + .to_string(), + )); + } + let requested_level = role_level(&normalized_role).unwrap_or_default(); + if requested_level > actor_level { + return Err(ABError::Forbidden( + "Cannot assign a role higher than your own organisation access level" + .to_string(), + )); + } + } + + let current_role = self + .organisation_user_role(&normalized_target, organisation) + .await? + .ok_or_else(|| { + ABError::BadRequest(format!( + "User is not a member of any role in organization {}", + organisation + )) + })?; + + if current_role == ROLE_OWNER + && normalized_role != ROLE_OWNER + && self.org_owner_count(organisation).await <= 1 + { + return Err(ABError::BadRequest( + "Cannot modify the last owner. Add another owner first.".to_string(), + )); + } + + self.set_org_role(&normalized_target, organisation, &normalized_role) + .await?; + + if matches!(normalized_role.as_str(), ROLE_ADMIN | ROLE_OWNER) { + self.grant_admin_on_all_org_apps(&normalized_target, organisation) + .await?; + } + Ok(()) + } + + async fn remove_organisation_user( + &self, + _state: &AppState, + actor_subject: &str, + organisation: &str, + target_subject: &str, + ) -> airborne_types::Result<()> { + let normalized_actor = normalize_subject(actor_subject)?; + let normalized_target = normalize_subject(target_subject)?; + self.ensure_organisation_exists(organisation).await?; + self.ensure_requester_can_modify_org_member( + &normalized_actor, + organisation, + &normalized_target, + ) + .await?; + + let current_role = self + .organisation_user_role(&normalized_target, organisation) + .await? + .ok_or_else(|| { + ABError::BadRequest(format!( + "User is not a member of any role in organization {}", + organisation + )) + })?; + + if current_role == ROLE_OWNER && self.org_owner_count(organisation).await <= 1 { + return Err(ABError::BadRequest( + "Cannot remove the last owner from the organization".to_string(), + )); + } + + self.remove_organisation_membership(&normalized_target, organisation) + .await + } + + async fn transfer_organisation_ownership( + &self, + _state: &AppState, + actor_subject: &str, + organisation: &str, + target_subject: &str, + ) -> airborne_types::Result<()> { + let normalized_actor = normalize_subject(actor_subject)?; + let normalized_target = normalize_subject(target_subject)?; + if normalized_actor == normalized_target { + return Err(ABError::BadRequest( + "Cannot transfer ownership to yourself".to_string(), + )); + } + self.ensure_organisation_exists(organisation).await?; + self.ensure_requester_can_modify_org_member( + &normalized_actor, + organisation, + &normalized_target, + ) + .await?; + + let actor_is_super_admin = self.has_super_admin_role(&normalized_actor).await?; + let actor_role = self + .organisation_user_role(&normalized_actor, organisation) + .await? + .unwrap_or_default(); + if !actor_is_super_admin && actor_role != ROLE_OWNER { + return Err(ABError::Forbidden( + "Ownership transfer requires owner role".to_string(), + )); + } + + let target_current_role = self + .organisation_user_role(&normalized_target, organisation) + .await? + .ok_or_else(|| { + ABError::BadRequest(format!( + "User is not a member of any role in organization {}", + organisation + )) + })?; + debug!( + "Transferring ownership in org {} from {} to {} (target current role: {})", + organisation, normalized_actor, normalized_target, target_current_role + ); + + self.set_org_role(&normalized_target, organisation, ROLE_OWNER) + .await?; + if actor_role == ROLE_OWNER { + self.set_org_role(&normalized_actor, organisation, ROLE_ADMIN) + .await?; + } + self.grant_admin_on_all_org_apps(&normalized_target, organisation) + .await?; + if actor_role == ROLE_OWNER { + self.grant_admin_on_all_org_apps(&normalized_actor, organisation) + .await?; + } + Ok(()) + } + + async fn list_application_users( + &self, + _state: &AppState, + organisation: &str, + application: &str, + ) -> airborne_types::Result> { + self.ensure_application_exists(organisation, application) + .await?; + let policies = self + .list_memberships(POLICY_SCOPE_APP, organisation, Some(application)) + .await?; + + let mut users: BTreeMap> = BTreeMap::new(); + for policy in policies { + users + .entry(policy.subject) + .or_default() + .insert(policy.role_key); + } + + let mut response = Vec::new(); + for (subject, role_set) in users { + let roles = highest_role_from_set(&role_set) + .map(|role| app_role_display_expansion(&role)) + .unwrap_or_default(); + response.push(AuthzUserInfo { + username: subject.clone(), + email: Some(subject), + roles, + }); + } + Ok(response) + } + + async fn add_application_user( + &self, + _state: &AppState, + actor_subject: &str, + organisation: &str, + application: &str, + target_subject: &str, + role: &str, + ) -> airborne_types::Result<()> { + let normalized_actor = normalize_subject(actor_subject)?; + let normalized_target = normalize_subject(target_subject)?; + let normalized_role = validate_app_role(role)?; + self.ensure_application_exists(organisation, application) + .await?; + self.ensure_requester_can_modify_app_member( + &normalized_actor, + organisation, + application, + &normalized_target, + ) + .await?; + + let target_org_level = self + .highest_org_access_level(&normalized_target, organisation) + .await? + .unwrap_or_default(); + if target_org_level == 0 { + return Err(ABError::BadRequest("User not found in org".to_string())); + } + + let actor_is_super_admin = self.has_super_admin_role(&normalized_actor).await?; + if !actor_is_super_admin { + let actor_level = self + .highest_app_access_level(&normalized_actor, organisation, application) + .await? + .unwrap_or_default(); + if canonical_system_role(&normalized_role).is_none() && actor_level < ADMIN.access { + return Err(ABError::Forbidden( + "Only application admin (or organisation admin/owner) can assign custom application roles" + .to_string(), + )); + } + let requested_level = role_level(&normalized_role).unwrap_or_default(); + if requested_level > actor_level { + return Err(ABError::Forbidden( + "Cannot assign a role higher than your own application access level" + .to_string(), + )); + } + } + + self.set_application_role( + &normalized_target, + organisation, + application, + &normalized_role, + ) + .await + } + + async fn update_application_user( + &self, + _state: &AppState, + actor_subject: &str, + organisation: &str, + application: &str, + target_subject: &str, + role: &str, + ) -> airborne_types::Result<()> { + let normalized_actor = normalize_subject(actor_subject)?; + let normalized_target = normalize_subject(target_subject)?; + let normalized_role = validate_app_role(role)?; + self.ensure_application_exists(organisation, application) + .await?; + self.ensure_requester_can_modify_app_member( + &normalized_actor, + organisation, + application, + &normalized_target, + ) + .await?; + + let actor_is_super_admin = self.has_super_admin_role(&normalized_actor).await?; + if !actor_is_super_admin { + let actor_level = self + .highest_app_access_level(&normalized_actor, organisation, application) + .await? + .unwrap_or_default(); + if canonical_system_role(&normalized_role).is_none() && actor_level < ADMIN.access { + return Err(ABError::Forbidden( + "Only application admin (or organisation admin/owner) can assign custom application roles" + .to_string(), + )); + } + let requested_level = role_level(&normalized_role).unwrap_or_default(); + if requested_level > actor_level { + return Err(ABError::Forbidden( + "Cannot assign a role higher than your own application access level" + .to_string(), + )); + } + } + + let current_role = self + .application_user_role(&normalized_target, organisation, application) + .await? + .ok_or_else(|| { + ABError::BadRequest(format!( + "User has no role in application {}/{}", + organisation, application + )) + })?; + + if current_role == ROLE_ADMIN + && normalized_role != ROLE_ADMIN + && self.app_admin_count(organisation, application).await <= 1 + { + let target_org_level = self + .highest_org_access_level(&normalized_target, organisation) + .await? + .unwrap_or_default(); + if target_org_level < ADMIN.access { + return Err(ABError::BadRequest( + "Cannot demote the last admin from the application. Applications must have at least one admin." + .to_string(), + )); + } + } + + self.set_application_role( + &normalized_target, + organisation, + application, + &normalized_role, + ) + .await + } + + async fn remove_application_user( + &self, + _state: &AppState, + actor_subject: &str, + organisation: &str, + application: &str, + target_subject: &str, + ) -> airborne_types::Result<()> { + let normalized_actor = normalize_subject(actor_subject)?; + let normalized_target = normalize_subject(target_subject)?; + self.ensure_application_exists(organisation, application) + .await?; + self.ensure_requester_can_modify_app_member( + &normalized_actor, + organisation, + application, + &normalized_target, + ) + .await?; + + let current_role = self + .application_user_role(&normalized_target, organisation, application) + .await? + .ok_or_else(|| { + ABError::BadRequest(format!( + "User has no role in application {}/{}", + organisation, application + )) + })?; + if current_role == ROLE_ADMIN && self.app_admin_count(organisation, application).await <= 1 + { + return Err(ABError::BadRequest( + "Cannot remove the last admin from the application. Applications must have at least one admin.".to_string(), + )); + } + + self.remove_policies_for_filter( + 0, + vec![ + normalized_target.clone(), + POLICY_SCOPE_APP.to_string(), + organisation.to_string(), + application.to_string(), + ], + ) + .await?; + self.remove_membership( + &normalized_target, + POLICY_SCOPE_APP, + organisation, + application, + ) + .await?; + Ok(()) + } + + async fn list_role_definitions( + &self, + _state: &AppState, + actor_subject: &str, + organisation: &str, + application: Option<&str>, + ) -> airborne_types::Result> { + let normalized_actor = normalize_subject(actor_subject)?; + let scope = if let Some(app_name) = application { + self.ensure_application_exists(organisation, app_name) + .await?; + self.ensure_actor_can_manage_app_roles(&normalized_actor, organisation, app_name) + .await?; + POLICY_SCOPE_APP + } else { + self.ensure_organisation_exists(organisation).await?; + self.ensure_actor_can_manage_org_roles(&normalized_actor, organisation) + .await?; + POLICY_SCOPE_ORG + }; + + let bindings = self.list_role_bindings(scope).await?; + let mut role_map: BTreeMap> = BTreeMap::new(); + for binding in bindings { + if canonical_system_role(&binding.role_key).is_none() + && is_reserved_role_management_permission(&binding.resource) + { + continue; + } + let permission = AuthzPermissionAttribute { + key: format!("{}.{}", binding.resource, binding.action), + resource: binding.resource, + action: binding.action, + }; + role_map + .entry(binding.role_key) + .or_default() + .push(permission); + } + + let default_roles: Vec<&str> = if scope == POLICY_SCOPE_ORG { + vec![ROLE_OWNER, ROLE_ADMIN, ROLE_WRITE, ROLE_READ] + } else { + vec![ROLE_ADMIN, ROLE_WRITE, ROLE_READ] + }; + for role in default_roles { + role_map.entry(role.to_string()).or_default(); + } + + let mut roles = role_map + .into_iter() + .map(|(role, mut permissions)| { + permissions.sort_by(|left, right| left.key.cmp(&right.key)); + permissions.dedup_by(|left, right| left.key == right.key); + AuthzRoleDefinition { + is_system: canonical_system_role(&role).is_some(), + role, + permissions, + } + }) + .collect::>(); + + roles.sort_by(|left, right| { + let left_key = role_sort_key(&left.role); + let right_key = role_sort_key(&right.role); + left_key + .cmp(&right_key) + .then_with(|| left.role.cmp(&right.role)) + }); + Ok(roles) + } + + async fn list_available_permissions( + &self, + _state: &AppState, + actor_subject: &str, + organisation: &str, + application: Option<&str>, + ) -> airborne_types::Result> { + let normalized_actor = normalize_subject(actor_subject)?; + let scope = if let Some(app_name) = application { + self.ensure_application_exists(organisation, app_name) + .await?; + self.ensure_actor_can_manage_app_roles(&normalized_actor, organisation, app_name) + .await?; + POLICY_SCOPE_APP + } else { + self.ensure_organisation_exists(organisation).await?; + self.ensure_actor_can_manage_org_roles(&normalized_actor, organisation) + .await?; + POLICY_SCOPE_ORG + }; + + let bindings = self.list_role_bindings(scope).await?; + let mut unique = BTreeSet::new(); + let mut permissions = Vec::new(); + for binding in bindings { + if canonical_system_role(&binding.role_key).is_none() { + continue; + } + if is_reserved_role_management_permission(&binding.resource) { + continue; + } + let key = format!("{}.{}", binding.resource, binding.action); + if unique.insert(key.clone()) { + permissions.push(AuthzPermissionAttribute { + key, + resource: binding.resource, + action: binding.action, + }); + } + } + permissions.sort_by(|left, right| left.key.cmp(&right.key)); + Ok(permissions) + } + + async fn upsert_custom_role( + &self, + _state: &AppState, + actor_subject: &str, + organisation: &str, + application: Option<&str>, + role: &str, + permissions: &[String], + ) -> airborne_types::Result<()> { + let normalized_actor = normalize_subject(actor_subject)?; + let scope = if let Some(app_name) = application { + self.ensure_application_exists(organisation, app_name) + .await?; + self.ensure_actor_can_manage_app_roles(&normalized_actor, organisation, app_name) + .await?; + POLICY_SCOPE_APP + } else { + self.ensure_organisation_exists(organisation).await?; + self.ensure_actor_can_manage_org_roles(&normalized_actor, organisation) + .await?; + POLICY_SCOPE_ORG + }; + + let normalized_role = if scope == POLICY_SCOPE_APP { + validate_app_role(role)? + } else { + validate_org_role(role)? + }; + + if canonical_system_role(&normalized_role).is_some() { + return Err(ABError::BadRequest( + "System roles cannot be modified as custom roles".to_string(), + )); + } + + if permissions.is_empty() { + return Err(ABError::BadRequest( + "Custom role must include at least one permission".to_string(), + )); + } + + let available_permissions = self + .list_available_permissions(_state, &normalized_actor, organisation, application) + .await?; + let available_keys = available_permissions + .iter() + .map(|permission| permission.key.clone()) + .collect::>(); + + let mut parsed_permissions = Vec::new(); + let mut unique_permissions = BTreeSet::new(); + for requested in permissions { + let (resource, action) = parse_permission_key(requested)?; + if is_reserved_role_management_permission(&resource) { + return Err(ABError::BadRequest(format!( + "Permission '{}.{}' is reserved for system role management and cannot be assigned to custom roles", + resource, action + ))); + } + let key = format!("{}.{}", resource, action); + if !available_keys.contains(&key) { + return Err(ABError::BadRequest(format!( + "Unknown permission '{}'. Use one from /permissions/list", + key + ))); + } + if unique_permissions.insert(key.clone()) { + parsed_permissions.push(AuthzPermissionAttribute { + key, + resource, + action, + }); + } + } + + self.upsert_role_bindings(scope, &normalized_role, &parsed_permissions) + .await + } + + async fn enforce_permissions_batch( + &self, + _state: &AppState, + subject: &str, + checks: &[AuthzPermissionCheck], + ) -> airborne_types::Result> { + if checks.is_empty() { + return Ok(Vec::new()); + } + + let normalized_subject = normalize_subject(subject)?; + let is_super_admin = self.has_super_admin_role(&normalized_subject).await?; + let guard = self.enforcer.read().await; + let mut decisions = Vec::with_capacity(checks.len().min(MAX_AUTHZ_BATCH_PREALLOC)); + + for check in checks { + if is_reserved_role_management_permission(&check.resource) { + let allowed = self + .can_manage_role_permissions( + &normalized_subject, + &check.organisation, + check.application.as_deref(), + &check.resource, + is_super_admin, + ) + .await?; + decisions.push(allowed); + continue; + } + + let scope = if check.application.is_some() { + POLICY_SCOPE_APP + } else { + POLICY_SCOPE_ORG + }; + let app = check.application.as_deref().unwrap_or("*"); + let permission = scoped_permission(scope, &check.resource, &check.action); + let allowed = guard + .enforce(( + normalized_subject.clone(), + scope, + check.organisation.as_str(), + app, + permission, + )) + .map_err(|error| { + ABError::InternalServerError(format!( + "Failed to evaluate permission policy: {error}" + )) + })?; + decisions.push(allowed); + } + + Ok(decisions) + } + + async fn enforce_permission( + &self, + _state: &AppState, + subject: &str, + organisation: &str, + application: Option<&str>, + resource: &str, + action: &str, + ) -> airborne_types::Result { + let normalized_subject = normalize_subject(subject)?; + let is_super_admin = self.has_super_admin_role(&normalized_subject).await?; + if is_reserved_role_management_permission(resource) { + return self + .can_manage_role_permissions( + &normalized_subject, + organisation, + application, + resource, + is_super_admin, + ) + .await; + } + + let scope = if application.is_some() { + POLICY_SCOPE_APP + } else { + POLICY_SCOPE_ORG + }; + let app = application.unwrap_or("*"); + let permission = scoped_permission(scope, resource, action); + + let guard = self.enforcer.read().await; + guard + .enforce((normalized_subject, scope, organisation, app, permission)) + .map_err(|error| { + ABError::InternalServerError(format!( + "Failed to evaluate permission policy: {error}" + )) + }) + } +} + +async fn ensure_role_hierarchy(enforcer: &mut Enforcer) -> airborne_types::Result<()> { + let role_hierarchy = [ + (ROLE_SUPER_ADMIN, ROLE_OWNER), + (ROLE_OWNER, ROLE_ADMIN), + (ROLE_ADMIN, ROLE_WRITE), + (ROLE_WRITE, ROLE_READ), + ]; + + for (parent, child) in role_hierarchy { + let _ = enforcer + .add_grouping_policy(vec![parent.to_string(), child.to_string()]) + .await + .map_err(|error| { + ABError::InternalServerError(format!( + "Failed to persist Casbin role hierarchy policy: {error}" + )) + })?; + } + Ok(()) +} + +fn normalize_subject(subject: &str) -> airborne_types::Result { + let normalized = subject.trim().to_ascii_lowercase(); + if normalized.is_empty() { + Err(ABError::BadRequest( + "Authorization subject cannot be empty".to_string(), + )) + } else { + Ok(normalized) + } +} + +fn normalize_subjects(subjects: Vec) -> airborne_types::Result> { + let mut values = BTreeSet::new(); + for subject in subjects { + values.insert(normalize_subject(&subject)?); + } + Ok(values.into_iter().collect()) +} + +fn normalize_policy_entry(entry: &PolicyEntry) -> airborne_types::Result { + let scope = entry.scope.trim().to_ascii_lowercase(); + let mut normalized = PolicyEntry { + subject: normalize_subject(&entry.subject)?, + scope: scope.clone(), + organisation: entry.organisation.trim().to_string(), + application: entry.application.trim().to_string(), + action: entry.action.trim().to_ascii_lowercase(), + }; + + match scope.as_str() { + POLICY_SCOPE_SYSTEM => { + if normalized.action != ROLE_SUPER_ADMIN { + return Err(ABError::BadRequest(format!( + "Invalid system role '{}'. Only '{}' is allowed", + normalized.action, ROLE_SUPER_ADMIN + ))); + } + normalized.organisation = "*".to_string(); + normalized.application = "*".to_string(); + } + POLICY_SCOPE_ORG => { + normalized.action = validate_org_role(&normalized.action)?; + if normalized.organisation.is_empty() { + return Err(ABError::BadRequest( + "Organisation cannot be empty for org-scope policy".to_string(), + )); + } + normalized.application = "*".to_string(); + } + POLICY_SCOPE_APP => { + normalized.action = validate_app_role(&normalized.action)?; + if normalized.organisation.is_empty() || normalized.application.is_empty() { + return Err(ABError::BadRequest( + "Organisation/application cannot be empty for app-scope policy".to_string(), + )); + } + } + _ => { + return Err(ABError::BadRequest(format!( + "Invalid policy scope '{}'", + scope + ))); + } + } + + Ok(normalized) +} + +fn validate_org_role(role: &str) -> airborne_types::Result { + let normalized = role.trim().to_ascii_lowercase(); + match normalized.as_str() { + ROLE_OWNER | ROLE_ADMIN | ROLE_WRITE | ROLE_READ => Ok(normalized), + _ if is_valid_custom_role_name(&normalized) => Ok(normalized), + _ => Err(ABError::BadRequest(format!( + "Invalid access level '{}'. Custom role keys may only contain a-z and _", + role + ))), + } +} + +fn validate_app_role(role: &str) -> airborne_types::Result { + let normalized = role.trim().to_ascii_lowercase(); + match normalized.as_str() { + ROLE_ADMIN | ROLE_WRITE | ROLE_READ => Ok(normalized), + _ if is_valid_custom_role_name(&normalized) => Ok(normalized), + _ => Err(ABError::BadRequest(format!( + "Invalid access level '{}'. Applications only support: read, write, admin; custom role keys may only contain a-z and _", + role + ))), + } +} + +fn role_level(role: &str) -> Option { + match canonical_system_role(role).as_deref() { + Some(ROLE_SUPER_ADMIN) => Some(5), + Some(ROLE_OWNER) => Some(OWNER.access), + Some(ROLE_ADMIN) => Some(ADMIN.access), + Some(ROLE_WRITE) => Some(WRITE.access), + Some(ROLE_READ) => Some(READ.access), + _ => None, + } +} + +fn canonical_system_role(role_key: &str) -> Option { + let normalized = role_key.trim().to_ascii_lowercase(); + let suffix = normalized.rsplit(':').next().unwrap_or(""); + let value = if matches!( + normalized.as_str(), + ROLE_SUPER_ADMIN | ROLE_OWNER | ROLE_ADMIN | ROLE_WRITE | ROLE_READ + ) { + normalized.as_str() + } else if matches!( + suffix, + ROLE_SUPER_ADMIN | ROLE_OWNER | ROLE_ADMIN | ROLE_WRITE | ROLE_READ + ) { + suffix + } else { + return None; + }; + Some(value.to_string()) +} + +fn is_valid_custom_role_name(role: &str) -> bool { + !role.is_empty() + && role.len() <= 64 + && role.chars().all(|ch| ch.is_ascii_lowercase() || ch == '_') +} + +fn highest_role_from_set(roles: &BTreeSet) -> Option { + let mut best: Option = None; + let mut best_level = 0; + + for role in roles { + if let Some(level) = role_level(role) { + if level > best_level { + best_level = level; + best = Some(role.clone()); + } + } + } + + best.or_else(|| roles.iter().next().cloned()) +} + +fn parse_permission_key(permission: &str) -> airborne_types::Result<(String, String)> { + let normalized = permission.trim().to_ascii_lowercase(); + let mut parts = normalized.split('.'); + let resource = parts.next().unwrap_or_default().trim().to_string(); + let action = parts.next().unwrap_or_default().trim().to_string(); + let has_extra = parts.next().is_some(); + + if resource.is_empty() || action.is_empty() || has_extra { + return Err(ABError::BadRequest(format!( + "Invalid permission '{}'. Expected format: resource.action", + permission + ))); + } + + if !resource + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_' || ch == '-') + || !action + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_' || ch == '-') + { + return Err(ABError::BadRequest(format!( + "Invalid permission '{}'. Resource/action must be lowercase slug values", + permission + ))); + } + + Ok((resource, action)) +} + +fn is_reserved_role_management_permission(resource: &str) -> bool { + matches!(resource, "organisation_role" | "application_role") +} + +fn org_role_display_expansion(role: &str) -> Vec { + let canonical = canonical_system_role(role); + let mut roles = match canonical.as_deref().unwrap_or(role) { + ROLE_OWNER => vec![ + ROLE_OWNER.to_string(), + ROLE_ADMIN.to_string(), + ROLE_WRITE.to_string(), + ROLE_READ.to_string(), + ], + ROLE_ADMIN => vec![ + ROLE_ADMIN.to_string(), + ROLE_WRITE.to_string(), + ROLE_READ.to_string(), + ], + ROLE_WRITE => vec![ROLE_WRITE.to_string(), ROLE_READ.to_string()], + ROLE_READ => vec![ROLE_READ.to_string()], + _ => vec![role.to_string()], + }; + sort_roles(&mut roles); + roles +} + +fn app_role_display_expansion(role: &str) -> Vec { + let canonical = canonical_system_role(role); + let mut roles = match canonical.as_deref().unwrap_or(role) { + ROLE_ADMIN => vec![ + ROLE_ADMIN.to_string(), + ROLE_WRITE.to_string(), + ROLE_READ.to_string(), + ], + ROLE_WRITE => vec![ROLE_WRITE.to_string(), ROLE_READ.to_string()], + ROLE_READ => vec![ROLE_READ.to_string()], + _ => vec![role.to_string()], + }; + sort_roles(&mut roles); + roles +} + +fn role_sort_key(role: &str) -> u8 { + match role { + ROLE_OWNER => 0, + ROLE_ADMIN => 1, + ROLE_WRITE => 2, + ROLE_READ => 3, + ROLE_SUPER_ADMIN => 4, + _ => 5, + } +} + +fn sort_roles(roles: &mut [String]) { + roles.sort_by_key(|left| role_sort_key(left)); +} diff --git a/airborne_server/src/provider/authz/migration.rs b/airborne_server/src/provider/authz/migration.rs new file mode 100644 index 00000000..b9081b9c --- /dev/null +++ b/airborne_server/src/provider/authz/migration.rs @@ -0,0 +1,247 @@ +use std::collections::BTreeSet; + +use diesel::{r2d2::ConnectionManager, PgConnection}; +use keycloak::{ + types::{GroupRepresentation, UserRepresentation}, + KeycloakAdmin, KeycloakAdminToken, +}; +use log::info; +use r2d2::Pool; +use reqwest::Client as HttpClient; +use url::Url; + +use crate::{ + config::AppConfig, + provider::authz::casbin::{CasbinAuthzProvider, PolicyEntry}, +}; + +fn trim_trailing_slash(value: &str) -> String { + value.trim_end_matches('/').to_string() +} + +pub fn parse_keycloak_admin_issuer(auth_admin_issuer: &str) -> Result<(String, String), String> { + let auth_admin_issuer = trim_trailing_slash(auth_admin_issuer); + let parsed_issuer = Url::parse(&auth_admin_issuer) + .map_err(|error| format!("AUTH_ADMIN_ISSUER must be a valid absolute URL: {error}"))?; + let path_segments: Vec<&str> = parsed_issuer + .path_segments() + .map(|segments| segments.filter(|segment| !segment.is_empty()).collect()) + .unwrap_or_default(); + let realms_index = path_segments + .iter() + .position(|segment| *segment == "realms") + .ok_or_else(|| { + "AUTH_ADMIN_ISSUER must contain '/realms/{realm}' for Keycloak admin APIs".to_string() + })?; + let realm = path_segments + .get(realms_index + 1) + .ok_or_else(|| { + "AUTH_ADMIN_ISSUER must include a realm segment after '/realms'".to_string() + })? + .to_string(); + let mut keycloak_base = format!( + "{}://{}", + parsed_issuer.scheme(), + parsed_issuer + .host_str() + .ok_or_else(|| "AUTH_ADMIN_ISSUER must include a host".to_string())? + ); + if let Some(port) = parsed_issuer.port() { + keycloak_base.push_str(&format!(":{port}")); + } + if realms_index > 0 { + keycloak_base.push('/'); + keycloak_base.push_str(&path_segments[..realms_index].join("/")); + } + Ok((trim_trailing_slash(&keycloak_base), realm)) +} + +async fn fetch_admin_token_from_config( + app_config: &AppConfig, +) -> Result { + let token_url = app_config + .auth_admin_token_url + .as_ref() + .ok_or_else(|| "AUTH_ADMIN_TOKEN_URL must be set for Keycloak import".to_string())?; + let client_id = app_config + .auth_admin_client_id + .as_ref() + .ok_or_else(|| "AUTH_ADMIN_CLIENT_ID must be set for Keycloak import".to_string())?; + let client_secret = app_config + .auth_admin_client_secret + .as_ref() + .ok_or_else(|| "AUTH_ADMIN_CLIENT_SECRET must be set for Keycloak import".to_string())?; + + let mut params: Vec<(&str, String)> = vec![ + ("grant_type", "client_credentials".to_string()), + ("client_id", client_id.clone()), + ("client_secret", client_secret.clone()), + ]; + if let Some(audience) = app_config.auth_admin_audience.clone() { + if !audience.trim().is_empty() { + params.push(("audience", audience)); + } + } + if let Some(scopes) = app_config.auth_admin_scopes.clone() { + if !scopes.trim().is_empty() { + params.push(("scope", scopes)); + } + } + + let response = HttpClient::new() + .post(token_url) + .form(¶ms) + .send() + .await + .map_err(|error| format!("Failed to request admin access token: {error}"))?; + + if !response.status().is_success() { + let error_text = response.text().await.unwrap_or_default(); + return Err(format!("Failed to get admin access token: {error_text}")); + } + + response + .json::() + .await + .map_err(|error| format!("Failed to parse admin access token response: {error}")) +} + +fn subject_from_user(user: &UserRepresentation) -> Option { + user.email + .as_ref() + .or(user.username.as_ref()) + .map(|subject| subject.trim().to_ascii_lowercase()) + .filter(|subject| !subject.is_empty()) +} + +fn map_group_path_to_policy(subject: &str, group: &GroupRepresentation) -> Option { + let path = group.path.as_deref()?; + let segments: Vec<&str> = path + .split('/') + .filter(|segment| !segment.is_empty()) + .collect(); + match segments.as_slice() { + ["super_admin"] => Some(PolicyEntry { + subject: subject.to_string(), + scope: "system".to_string(), + organisation: "*".to_string(), + application: "*".to_string(), + action: "super_admin".to_string(), + }), + [org, role] + if matches!( + (*role).to_ascii_lowercase().as_str(), + "owner" | "admin" | "write" | "read" + ) => + { + Some(PolicyEntry { + subject: subject.to_string(), + scope: "org".to_string(), + organisation: (*org).to_string(), + application: "*".to_string(), + action: (*role).to_ascii_lowercase(), + }) + } + [org, app, role] + if matches!( + (*role).to_ascii_lowercase().as_str(), + "admin" | "write" | "read" + ) => + { + Some(PolicyEntry { + subject: subject.to_string(), + scope: "app".to_string(), + organisation: (*org).to_string(), + application: (*app).to_string(), + action: (*role).to_ascii_lowercase(), + }) + } + _ => None, + } +} + +pub async fn import_keycloak_authz_to_casbin( + app_config: &AppConfig, + db_pool: Pool>, + apply: bool, +) -> Result<(), String> { + let auth_admin_issuer = app_config + .auth_admin_issuer + .as_ref() + .ok_or_else(|| "AUTH_ADMIN_ISSUER must be set for Keycloak import".to_string())?; + let (keycloak_url, realm) = parse_keycloak_admin_issuer(auth_admin_issuer)?; + let admin_token = fetch_admin_token_from_config(app_config).await?; + let admin = KeycloakAdmin::new(&keycloak_url, admin_token, HttpClient::new()); + + let users = admin + .realm_users_get( + &realm, + Some(true), + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + ) + .await + .map_err(|error| format!("Failed to list Keycloak users: {error}"))?; + + let mut entries = BTreeSet::new(); + let mut ignored_groups = 0usize; + let mut skipped_users = 0usize; + + for user in users { + let Some(user_id) = user.id.as_ref() else { + skipped_users += 1; + continue; + }; + let Some(subject) = subject_from_user(&user) else { + skipped_users += 1; + continue; + }; + + let groups = admin + .realm_users_with_user_id_groups_get(&realm, user_id, None, None, None, None) + .await + .map_err(|error| format!("Failed to fetch groups for user '{subject}': {error}"))?; + for group in groups { + if let Some(entry) = map_group_path_to_policy(&subject, &group) { + entries.insert(entry); + } else { + ignored_groups += 1; + } + } + } + + let casbin_provider = CasbinAuthzProvider::new(Vec::new(), db_pool.clone(), None) + .await + .map_err(|error| format!("Failed to initialize Casbin provider: {error}"))?; + + let policies: Vec = entries.into_iter().collect(); + let imported = casbin_provider + .import_policy_entries(&policies, apply) + .await + .map_err(|error| format!("Failed to import policies: {error}"))?; + + if apply { + info!( + "Applied {} Keycloak-derived Casbin policies ({} users skipped, {} groups ignored)", + imported, skipped_users, ignored_groups + ); + } else { + info!( + "Dry run generated {} Keycloak-derived Casbin policies ({} users skipped, {} groups ignored)", + imported, skipped_users, ignored_groups + ); + } + + Ok(()) +} diff --git a/airborne_server/src/provider/authz/permission.rs b/airborne_server/src/provider/authz/permission.rs new file mode 100644 index 00000000..c3417320 --- /dev/null +++ b/airborne_server/src/provider/authz/permission.rs @@ -0,0 +1,107 @@ +use actix_web::web::{Data, ReqData}; + +use crate::{ + middleware::auth::AuthResponse, + types as airborne_types, + types::{ABError, AppState}, +}; + +#[derive(Debug)] +pub struct EndpointPermissionBinding { + pub method: &'static str, + pub path: &'static str, + pub resource: &'static str, + pub action: &'static str, + pub org_roles: &'static [&'static str], + pub app_roles: &'static [&'static str], + pub allow_org: bool, + pub allow_app: bool, +} + +#[allow(clippy::too_many_arguments)] +impl EndpointPermissionBinding { + pub const fn new( + method: &'static str, + path: &'static str, + resource: &'static str, + action: &'static str, + org_roles: &'static [&'static str], + app_roles: &'static [&'static str], + allow_org: bool, + allow_app: bool, + ) -> Self { + Self { + method, + path, + resource, + action, + org_roles, + app_roles, + allow_org, + allow_app, + } + } +} + +inventory::collect!(EndpointPermissionBinding); + +pub fn scoped_permission(scope: &str, resource: &str, action: &str) -> String { + format!("{scope}:{resource}.{action}") +} + +pub async fn enforce_endpoint_permission( + state: &Data, + auth_response: &ReqData, + resource: &str, + action: &str, + allow_org: bool, + allow_app: bool, +) -> airborne_types::Result<()> { + let auth = auth_response.clone().into_inner(); + if auth.is_super_admin { + return Ok(()); + } + + let mut allowed = false; + + if allow_app { + if let (Some(org), Some(app)) = (auth.organisation.clone(), auth.application.clone()) { + allowed = state + .authz_provider + .enforce_permission( + state.get_ref(), + &auth.sub, + &org.name, + Some(&app.name), + resource, + action, + ) + .await?; + } + } + + if !allowed && allow_org { + if let Some(org) = auth.organisation { + allowed = state + .authz_provider + .enforce_permission( + state.get_ref(), + &auth.sub, + &org.name, + None, + resource, + action, + ) + .await?; + } + } + + if allowed { + Ok(()) + } else { + Err(ABError::Forbidden(format!( + "Missing permission for {}.{}", + resource, action + ))) + } +} diff --git a/airborne_server/src/release.rs b/airborne_server/src/release.rs index 7a7fcc65..06167e01 100644 --- a/airborne_server/src/release.rs +++ b/airborne_server/src/release.rs @@ -14,7 +14,7 @@ use crate::{ file::utils::parse_file_key, - middleware::auth::{validate_user, Auth, AuthResponse, ADMIN, READ, WRITE}, + middleware::auth::{require_org_and_app, Auth, AuthResponse}, release::types::*, types as airborne_types, types::{ABError, AppState, PaginatedQuery, PaginatedResponse, WithHeaders}, @@ -25,6 +25,7 @@ use actix_web::{ web::{self, Json, Path, Query}, Scope, }; +use airborne_authz_macros::authz; use aws_smithy_types::Document; use chrono::{DateTime, Utc}; use http::{HeaderValue, StatusCode}; @@ -90,6 +91,12 @@ pub fn add_public_routes() -> Scope { .service(serve_release_v2) } +#[authz( + resource = "release", + action = "read", + org_roles = ["owner", "admin", "write", "read"], + app_roles = ["admin", "write", "read"] +)] #[get("/{release_id}")] async fn get_release( release_id: Path, @@ -104,17 +111,10 @@ async fn get_release( } let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), READ) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; let superposition_org_id_from_env = state.env.superposition_org_id.clone(); let workspace_name = get_workspace_name_for_application( @@ -351,6 +351,12 @@ async fn get_release( Ok(Json(resp)) } +#[authz( + resource = "release", + action = "create", + org_roles = ["owner", "admin", "write"], + app_roles = ["admin", "write"] +)] #[post("")] async fn create_release( req: Json, @@ -358,17 +364,10 @@ async fn create_release( state: web::Data, ) -> airborne_types::Result> { let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), WRITE) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; let workspace_name = get_workspace_name_for_application( state.db_pool.clone(), @@ -623,6 +622,12 @@ async fn create_release( })) } +#[authz( + resource = "release", + action = "read", + org_roles = ["owner", "admin", "write", "read"], + app_roles = ["admin", "write", "read"] +)] #[get("/list")] async fn list_releases( pagination_query: Query, @@ -632,17 +637,10 @@ async fn list_releases( state: web::Data, ) -> airborne_types::Result>> { let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), READ) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; let superposition_org_id_from_env = state.env.superposition_org_id.clone(); let workspace_name = get_workspace_name_for_application( @@ -913,6 +911,12 @@ async fn list_releases( })) } +#[authz( + resource = "release", + action = "ramp", + org_roles = ["owner", "admin", "write"], + app_roles = ["admin", "write"] +)] #[post("/{release_id}/ramp")] async fn ramp_release( release_id: Path, @@ -921,17 +925,10 @@ async fn ramp_release( state: web::Data, ) -> airborne_types::Result> { let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), WRITE) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; let experiment_id = release_id.to_string(); @@ -1018,6 +1015,12 @@ async fn ramp_experiment( Ok(()) } +#[authz( + resource = "release", + action = "conclude", + org_roles = ["owner", "admin", "write"], + app_roles = ["admin", "write"] +)] #[post("/{release_id}/conclude")] async fn conclude_release( release_id: Path, @@ -1026,17 +1029,10 @@ async fn conclude_release( state: web::Data, ) -> airborne_types::Result> { let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), WRITE) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; let experiment_id = release_id.to_string(); @@ -1137,6 +1133,12 @@ async fn conclude_release( })) } +#[authz( + resource = "release", + action = "discard", + org_roles = ["owner", "admin", "write"], + app_roles = ["admin", "write"] +)] #[post("/{release_id}/discard")] async fn discard_release( release_id: Path, @@ -1145,17 +1147,10 @@ async fn discard_release( state: web::Data, ) -> airborne_types::Result> { let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), WRITE) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; let experiment_id = release_id.to_string(); @@ -1523,6 +1518,12 @@ async fn serve_release_handler( .status(StatusCode::OK)) } +#[authz( + resource = "release", + action = "update", + org_roles = ["owner", "admin", "write"], + app_roles = ["admin", "write"] +)] #[put("/{release_id}")] async fn update_release( path: Path, @@ -1531,17 +1532,10 @@ async fn update_release( state: web::Data, ) -> airborne_types::Result> { let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), WRITE) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; let workspace_name = get_workspace_name_for_application( state.db_pool.clone(), diff --git a/airborne_server/src/service_account.rs b/airborne_server/src/service_account.rs new file mode 100644 index 00000000..da14228c --- /dev/null +++ b/airborne_server/src/service_account.rs @@ -0,0 +1,336 @@ +pub mod types; + +use actix_web::{ + delete, get, post, + web::{self, Json, ReqData}, + HttpMessage, HttpRequest, Scope, +}; +use airborne_authz_macros::authz; +use chrono::Utc; +use diesel::prelude::*; +use log::info; + +use crate::{ + middleware::auth::{require_scope_name, AuthResponse}, + run_blocking, + service_account::types::*, + types as airborne_types, + types::{ABError, AppState, ListResponse}, + utils::{ + db::{ + models::ServiceAccountEntry, + schema::hyperotaserver::service_accounts::{ + client_id as sa_client_id, organisation as sa_org, table as service_accounts_table, + }, + }, + encryption::generate_random_key, + }, +}; + +const MAX_SERVICE_ACCOUNT_NAME_LENGTH: usize = 50; +const SERVICE_ACCOUNT_EMAIL_DOMAIN: &str = "service-account.airborne.juspay.in"; + +pub fn add_routes() -> Scope { + Scope::new("") + .service(create_service_account) + .service(list_service_accounts) + .service(delete_service_account) + .service(rotate_service_account) +} + +fn validate_service_account_name(name: &str) -> airborne_types::Result { + let trimmed = name.trim().to_ascii_lowercase(); + + if trimmed.is_empty() { + return Err(ABError::BadRequest( + "Service account name cannot be empty".to_string(), + )); + } + + if trimmed.len() > MAX_SERVICE_ACCOUNT_NAME_LENGTH { + return Err(ABError::BadRequest( + "Service account name is too long".to_string(), + )); + } + + if !trimmed + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return Err(ABError::BadRequest( + "Service account name can only contain alphanumeric characters, hyphens, and underscores" + .to_string(), + )); + } + + Ok(trimmed) +} + +fn build_service_account_email(name: &str, organisation: &str) -> String { + let org_sanitized = organisation.trim().to_ascii_lowercase().replace(' ', "-"); + format!( + "{}.{}@{}", + name, org_sanitized, SERVICE_ACCOUNT_EMAIL_DOMAIN + ) +} + +async fn generate_random_password() -> airborne_types::Result { + generate_random_key().await +} + +async fn get_org_context(req: &HttpRequest) -> airborne_types::Result<(String, AuthResponse)> { + let auth = req + .extensions() + .get::() + .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)) +} + +#[authz( + resource = "service_account", + action = "create", + org_roles = ["owner", "admin"], + app_roles = [] +)] +#[post("")] +async fn create_service_account( + req: HttpRequest, + body: Json, + auth_response: ReqData, + state: web::Data, +) -> airborne_types::Result> { + if !state.authn_provider.supports_service_accounts() { + return Err(ABError::BadRequest( + "Service accounts are not supported for configured AuthN provider".to_string(), + )); + } + + let (organisation, auth) = get_org_context(&req).await?; + let body = body.into_inner(); + + let name = validate_service_account_name(&body.name)?; + let email = build_service_account_email(&name, &organisation); + let password = generate_random_password().await?; + + // Create user in OIDC provider and get offline refresh token + let token = state + .authn_provider + .create_service_account_user(state.get_ref(), &name, &email, &password) + .await?; + + // The refresh token IS the client_secret — nothing stored in DB + let client_secret = token.refresh_token; + + let client_uid = uuid::Uuid::new_v4(); + let entry = ServiceAccountEntry { + client_id: client_uid, + name: name.clone(), + email: email.clone(), + description: body.description, + organisation: organisation.clone(), + created_by: auth.sub.clone(), + created_at: Utc::now(), + }; + + let pool = state.db_pool.clone(); + run_blocking!({ + let mut conn = pool.get()?; + diesel::insert_into(service_accounts_table) + .values(&entry) + .execute(&mut conn) + .map_err(|e| { + log::error!("[CREATE SERVICE ACCOUNT] DB insert failed: {}", e); + ABError::InternalServerError(format!("Failed to create service account: {}", e)) + })?; + Ok(()) + })?; + + // Add service account as org member with requested role + state + .authz_provider + .add_organisation_user( + state.get_ref(), + &auth.sub, + &organisation, + &email, + &body.role, + ) + .await?; + + info!( + "Service account '{}' created in org '{}' by '{}'", + name, organisation, auth.sub + ); + + Ok(Json(CreateServiceAccountResponse { + client_id: client_uid, + client_secret, + email, + name, + })) +} + +#[authz( + resource = "service_account", + action = "read", + org_roles = ["owner", "admin"], + app_roles = [] +)] +#[get("")] +async fn list_service_accounts( + req: HttpRequest, + auth_response: ReqData, + state: web::Data, +) -> airborne_types::Result>>> { + let (organisation, _auth) = get_org_context(&req).await?; + + let pool = state.db_pool.clone(); + let entries = run_blocking!({ + let mut conn = pool.get()?; + service_accounts_table + .filter(sa_org.eq(&organisation)) + .load::(&mut conn) + .map_err(|e| { + log::error!("[LIST SERVICE ACCOUNTS] DB fetch failed: {}", e); + ABError::InternalServerError(format!("Failed to list service accounts: {}", e)) + }) + })?; + + let data = entries + .into_iter() + .map(|entry| ServiceAccountListEntry { + client_id: entry.client_id, + name: entry.name, + email: entry.email, + description: entry.description, + created_by: entry.created_by, + created_at: entry.created_at, + }) + .collect(); + + Ok(Json(ListResponse { data })) +} + +#[authz( + resource = "service_account", + action = "delete", + org_roles = ["owner", "admin"], + app_roles = [] +)] +#[delete("/{client_id}")] +async fn delete_service_account( + req: HttpRequest, + path: web::Path, + auth_response: ReqData, + state: web::Data, +) -> airborne_types::Result> { + let (organisation, auth) = get_org_context(&req).await?; + let target_client_id = path.into_inner(); + + // Load service account from DB + let pool = state.db_pool.clone(); + let org_filter = organisation.clone(); + let entry = run_blocking!({ + let mut conn = pool.get()?; + service_accounts_table + .filter(sa_client_id.eq(&target_client_id)) + .filter(sa_org.eq(&org_filter)) + .first::(&mut conn) + .map_err(|_| ABError::NotFound("Service account not found".to_string())) + })?; + + // Remove from AuthZ memberships + let _ = state + .authz_provider + .remove_organisation_user(state.get_ref(), &auth.sub, &organisation, &entry.email) + .await; + + // Delete user from OIDC provider (best effort — invalidates all tokens) + let _ = state + .authn_provider + .delete_user(state.get_ref(), &entry.name) + .await; + + // Delete from database + let pool = state.db_pool.clone(); + run_blocking!({ + let mut conn = pool.get()?; + diesel::delete(service_accounts_table.filter(sa_client_id.eq(&target_client_id))) + .execute(&mut conn) + .map_err(|e| { + log::error!("[DELETE SERVICE ACCOUNT] DB delete failed: {}", e); + ABError::InternalServerError(format!("Failed to delete service account: {}", e)) + })?; + Ok(()) + })?; + + info!( + "Service account '{}' deleted from org '{}' by '{}'", + entry.name, organisation, auth.sub + ); + + Ok(Json(DeleteServiceAccountResponse { success: true })) +} + +#[authz( + resource = "service_account", + action = "create", + org_roles = ["owner", "admin"], + app_roles = [] +)] +#[post("/{client_id}/rotate")] +async fn rotate_service_account( + req: HttpRequest, + path: web::Path, + auth_response: ReqData, + state: web::Data, +) -> airborne_types::Result> { + if !state.authn_provider.supports_service_accounts() { + return Err(ABError::BadRequest( + "Service accounts are not supported for configured AuthN provider".to_string(), + )); + } + + let (organisation, _auth) = get_org_context(&req).await?; + let target_client_id = path.into_inner(); + + // Load service account from DB + let pool = state.db_pool.clone(); + let org_filter = organisation.clone(); + let entry = run_blocking!({ + let mut conn = pool.get()?; + service_accounts_table + .filter(sa_client_id.eq(&target_client_id)) + .filter(sa_org.eq(&org_filter)) + .first::(&mut conn) + .map_err(|_| ABError::NotFound("Service account not found".to_string())) + })?; + + // Delete and recreate the OIDC user with new credentials + let password = generate_random_password().await?; + let _ = state + .authn_provider + .delete_user(state.get_ref(), &entry.name) + .await; + + let token = state + .authn_provider + .create_service_account_user(state.get_ref(), &entry.name, &entry.email, &password) + .await?; + + // New refresh token is the new client_secret + let client_secret = token.refresh_token; + + info!( + "Service account '{}' credentials rotated in org '{}'", + entry.name, organisation + ); + + Ok(Json(RotateServiceAccountResponse { + client_id: target_client_id, + client_secret, + })) +} diff --git a/airborne_server/src/service_account/types.rs b/airborne_server/src/service_account/types.rs new file mode 100644 index 00000000..ff3bd274 --- /dev/null +++ b/airborne_server/src/service_account/types.rs @@ -0,0 +1,39 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize)] +pub struct CreateServiceAccountRequest { + pub name: String, + #[serde(default)] + pub description: String, + pub role: String, +} + +#[derive(Serialize)] +pub struct CreateServiceAccountResponse { + pub client_id: uuid::Uuid, + pub client_secret: String, + pub email: String, + pub name: String, +} + +#[derive(Serialize)] +pub struct ServiceAccountListEntry { + pub client_id: uuid::Uuid, + pub name: String, + pub email: String, + pub description: String, + pub created_by: String, + pub created_at: DateTime, +} + +#[derive(Serialize)] +pub struct RotateServiceAccountResponse { + pub client_id: uuid::Uuid, + pub client_secret: String, +} + +#[derive(Serialize)] +pub struct DeleteServiceAccountResponse { + pub success: bool, +} diff --git a/airborne_server/src/token.rs b/airborne_server/src/token.rs index 5fca42ca..38f3e8e2 100644 --- a/airborne_server/src/token.rs +++ b/airborne_server/src/token.rs @@ -1,7 +1,7 @@ pub mod types; use crate::{ - middleware::auth::{validate_user, Auth, AuthResponse, ADMIN, READ}, + middleware::auth::{require_org_and_app, Auth, AuthResponse}, run_blocking, token::types::*, types as airborne_types, @@ -10,15 +10,18 @@ use crate::{ utils::{ db::{ models::UserCredentialsEntry, - schema::hyperotaserver::user_credentials::{ - application as cred_app, client_id as uid, created_at, organisation as cred_org, - table as user_credentials_table, username, + schema::hyperotaserver::{ + service_accounts::{client_id as sa_uid, table as service_accounts_table}, + user_credentials::{ + application as cred_app, client_id as uid, created_at, + organisation as cred_org, table as user_credentials_table, username, + }, }, }, encryption::{decrypt_string, encrypt_string, generate_random_key}, - keycloak::decode_jwt_token, }, }; +use airborne_authz_macros::authz; use actix_web::{ delete, get, post, @@ -39,13 +42,22 @@ pub fn add_scopes(path: &str) -> Scope { ) } +#[authz( + resource = "token", + action = "create", + org_roles = ["owner", "admin"], + app_roles = ["admin"] +)] #[post("")] async fn create_token( req: Json, auth_response: ReqData, state: web::Data, ) -> airborne_types::Result> { + state.authn_provider.ensure_password_login_supported()?; + let key = generate_random_key().await?; + let req = req.into_inner(); let auth_response = auth_response.into_inner(); if auth_response.username != req.name { @@ -53,77 +65,59 @@ async fn create_token( return Err(ABError::Unauthorized("Username mismatch".to_string())); } - let (org_name, app_name) = match validate_user(auth_response.organisation.clone(), ADMIN) { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), ADMIN) - .map(|app_name| (org_name, app_name)) - }), - }?; - - let url = state.env.keycloak_url.clone(); - let client_id = state.env.client_id.clone(); - let secret = state.env.secret.clone(); - let realm = state.env.realm.clone(); - let url = format!("{}/realms/{}/protocol/openid-connect/token", url, realm); - let client = reqwest::Client::new(); - let params = [ - ("client_id", client_id), - ("client_secret", secret), - ("grant_type", "password".to_string()), - ("username", req.name.clone()), - ("password", req.password.clone()), - ("scope", "offline_access".to_string()), // so that refresh token never expire - ]; - - let response = client.post(&url).form(¶ms).send().await.map_err(|e| { - log::error!("[CREATE TOKEN] Keycloak request failed: {}", e); - ABError::InternalServerError(e.to_string()) - })?; - - if response.status().is_success() { - let token: UserToken = response.json().await.map_err(|e| { - log::error!("[CREATE TOKEN] Failed to parse Keycloak response: {}", e); - ABError::InternalServerError(e.to_string()) - })?; + let (org_name, app_name) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; + + let login_credentials = crate::user::types::UserCredentials { + name: req.name.clone(), + password: req.password.clone(), + first_name: None, + last_name: None, + email: None, + }; + let token = state + .authn_provider + .login_with_password_for_pat(state.get_ref(), &login_credentials) + .await?; - let client_uid = uuid::Uuid::new_v4(); - let encrypted_refresh_token = encrypt_string(&token.refresh_token.clone(), &key).await?; + let client_uid = uuid::Uuid::new_v4(); + let encrypted_refresh_token = encrypt_string(&token.refresh_token.clone(), &key).await?; - let new_cred = UserCredentialsEntry { - client_id: client_uid, - username: req.name.clone(), - password: encrypted_refresh_token, - organisation: org_name.clone(), - application: app_name.clone(), - created_at: Utc::now(), - }; - let pool = state.db_pool.clone(); - run_blocking!({ - let mut conn = pool.get()?; - diesel::insert_into(user_credentials_table) - .values(&new_cred) - .execute(&mut conn) - .map_err(|e: diesel::result::Error| { - log::error!("[CREATE TOKEN] DB insert failed: {}", e); - ABError::InternalServerError(format!("DB insert failed: {}", e)) - })?; - Ok(()) - })?; + let new_cred = UserCredentialsEntry { + client_id: client_uid, + username: req.name.clone(), + password: encrypted_refresh_token, + organisation: org_name.clone(), + application: app_name.clone(), + created_at: Utc::now(), + }; + let pool = state.db_pool.clone(); + run_blocking!({ + let mut conn = pool.get()?; + diesel::insert_into(user_credentials_table) + .values(&new_cred) + .execute(&mut conn) + .map_err(|e: diesel::result::Error| { + log::error!("[CREATE TOKEN] DB insert failed: {}", e); + ABError::InternalServerError(format!("DB insert failed: {}", e)) + })?; + Ok(()) + })?; - Ok(Json(PersonalAccessToken { - client_id: client_uid, - client_secret: key, - })) - } else { - log::error!("[CREATE TOKEN] Keycloak authentication failed: wrong password"); - Err(ABError::Forbidden("Invalid Credentials".to_string())) - } + Ok(Json(PersonalAccessToken { + client_id: client_uid, + client_secret: key, + })) } +#[authz( + resource = "token", + action = "create", + org_roles = ["owner", "admin"], + app_roles = ["admin"] +)] #[post("/oauth")] async fn create_token_oauth( req: HttpRequest, @@ -131,51 +125,47 @@ async fn create_token_oauth( auth_response: ReqData, state: web::Data, ) -> actix_web::Result, ABError> { + state + .authn_provider + .ensure_oidc_login_enabled(state.get_ref())?; + let oauth_req = body.into_inner(); let auth_response = auth_response.into_inner(); - let (org_name, app_name) = match validate_user(auth_response.organisation.clone(), ADMIN) { - Ok(org_name) => auth_response - .application - .ok_or_else(|| { - log::error!("[CREATE TOKEN OAUTH] Access denied: no application access"); - ABError::Unauthorized("No Access".to_string()) - }) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), ADMIN) - .map(|app_name| (org_name, app_name)) - }), - }?; - - let token_response = match exchange_code_for_token(&oauth_req.code, &req, &state).await { - Ok(response) => response, - Err(e) => { - log::error!("[CREATE TOKEN OAUTH] Token exchange failed: {:?}", e); - return Err(e); - } - }; + let (org_name, app_name) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; - let token_data = decode_jwt_token( - &token_response.access_token, - &state.env.keycloak_public_key, - &state.env.client_id, - ) - .map_err(|e| { - log::error!("[CREATE TOKEN OAUTH] Token decode failed: {:?}", e); - ABError::BadRequest("Invalid token".to_string()) - })?; + let token_response = + match exchange_code_for_token(&oauth_req.code, oauth_req.state.as_deref(), &req, &state) + .await + { + Ok(response) => response, + Err(e) => { + log::error!("[CREATE TOKEN OAUTH] Token exchange failed: {:?}", e); + return Err(e); + } + }; - let oauth_username = token_data.claims.preferred_username.ok_or_else(|| { - log::error!("[CREATE TOKEN OAUTH] No username in OAuth token"); - ABError::Unauthorized("Invalid OAuth token".to_string()) - })?; + let token_data = state + .authn_provider + .verify_access_token(state.get_ref(), &token_response.access_token) + .await + .map_err(|e| { + log::error!("[CREATE TOKEN OAUTH] Token decode failed: {:?}", e); + ABError::BadRequest("Invalid token".to_string()) + })?; + + let oauth_subject = state + .authz_provider + .subject_from_claims(&token_data.claims)?; - if oauth_username != auth_response.username { - log::error!("[CREATE TOKEN OAUTH] Username mismatch",); + if oauth_subject != auth_response.sub { + log::error!("[CREATE TOKEN OAUTH] Subject mismatch"); return Err(ABError::Unauthorized( - "Google account does not match your logged-in account".to_string(), + "OAuth account does not match your logged-in account".to_string(), )); } @@ -216,6 +206,12 @@ async fn create_token_oauth( })) } +#[authz( + resource = "token", + action = "delete", + org_roles = ["owner", "admin"], + app_roles = ["admin"] +)] #[delete("{client_id}")] async fn delete_token( client_id: web::Path, @@ -223,19 +219,10 @@ async fn delete_token( state: web::Data, ) -> airborne_types::Result> { let auth_response = auth_response.into_inner(); - let (_organisation, _application) = - match validate_user(auth_response.organisation.clone(), ADMIN) { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => { - validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), ADMIN) - .map(|app_name| (org_name, app_name)) - }) - } - }?; + let (_organisation, _application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; let pool = state.db_pool.clone(); run_blocking!({ let mut conn = pool.get()?; @@ -251,34 +238,30 @@ async fn delete_token( Ok(Json(DeleteTokenResponse { success: true })) } +#[authz( + resource = "token", + action = "read", + org_roles = ["owner", "admin"], + app_roles = ["admin"] +)] #[get("list")] async fn list_tokens( auth_response: ReqData, state: web::Data, ) -> airborne_types::Result>>> { let auth_response = auth_response.into_inner(); - let (organisation, application) = match validate_user(auth_response.organisation.clone(), ADMIN) - { - Ok(org_name) => auth_response - .application - .ok_or_else(|| ABError::Forbidden("No Access".to_string())) - .map(|access| (org_name, access.name)), - Err(_) => validate_user(auth_response.organisation.clone(), READ).and_then(|org_name| { - validate_user(auth_response.application.clone(), ADMIN) - .map(|app_name| (org_name, app_name)) - }), - }?; + let (organisation, application) = require_org_and_app( + auth_response.organisation.clone(), + auth_response.application.clone(), + )?; let pool = state.db_pool.clone(); let result = run_blocking!({ let mut conn = pool.get()?; let results = user_credentials_table - .filter( - username - .eq(&auth_response.username) - .and(cred_org.eq(&organisation)) - .and(cred_app.eq(&application)), - ) + .filter(username.eq(&auth_response.username)) + .filter(cred_org.eq(&organisation)) + .filter(cred_app.eq(&application)) .select((uid, created_at)) .load::<(uuid::Uuid, DateTime)>(&mut conn) .map_err(|e| { @@ -307,77 +290,63 @@ async fn issue_token( let pool = state.db_pool.clone(); let client_id = req.client_id; - let user = run_blocking!({ - let mut conn = pool.get()?; - let user = user_credentials_table - .filter(uid.eq(&client_id)) - .first::(&mut conn) - .map_err(|e| { - log::error!("[ISSUE TOKEN] Failed to load user credentials: {}", e); - ABError::Unauthorized("Invalid credentials".to_string()) - })?; - Ok(user) - })?; - log::info!("[ISSUE TOKEN] User credentials loaded successfully"); - - let decrypted_refresh_token = decrypt_string(&user.password, &req.client_secret) - .await - .map_err(|e| { - log::error!("[ISSUE TOKEN] Failed to decrypt refresh token: {:?}", e); - ABError::Unauthorized("Invalid credentials".to_string()) + // Try user_credentials first (PAT — needs decryption), + // then fall back to service_accounts (client_secret IS the refresh token). + let refresh_token = { + let pat_result = run_blocking!({ + let mut conn = pool.get()?; + user_credentials_table + .filter(uid.eq(&client_id)) + .first::(&mut conn) + .optional() + .map_err(|e| { + log::error!("[ISSUE TOKEN] DB error: {}", e); + ABError::InternalServerError("Database error".to_string()) + }) })?; - log::info!( - "[ISSUE TOKEN] Refresh token decrypted successfully, length: {}, first 10 chars: {}", - decrypted_refresh_token.len(), - &decrypted_refresh_token.chars().take(10).collect::() - ); - - let url = state.env.keycloak_url.clone(); - let client_id = state.env.client_id.clone(); - let secret = state.env.secret.clone(); - let realm = state.env.realm.clone(); - let url = format!("{}/realms/{}/protocol/openid-connect/token", url, realm); - - log::info!("[ISSUE TOKEN] Keycloak URL: {}", url); - log::info!("[ISSUE TOKEN] Realm: {}", realm); - - let client = reqwest::Client::new(); - let params = [ - ("client_id", client_id), - ("client_secret", secret), - ("grant_type", "refresh_token".to_string()), - ("refresh_token", decrypted_refresh_token), - ]; - - log::info!("[ISSUE TOKEN] Sending token refresh request to Keycloak"); - - let response = client.post(&url).form(¶ms).send().await.map_err(|e| { - log::error!("[ISSUE TOKEN] Keycloak request failed: {}", e); - ABError::InternalServerError("Authentication service unavailable".to_string()) - })?; + if let Some(user) = pat_result { + // PAT: decrypt the stored refresh token using client_secret as key + decrypt_string(&user.password, &req.client_secret) + .await + .map_err(|e| { + log::error!("[ISSUE TOKEN] Failed to decrypt refresh token: {:?}", e); + ABError::Unauthorized("Invalid credentials".to_string()) + })? + } else { + // Check if it's a service account — client_secret is the refresh token directly + let pool2 = state.db_pool.clone(); + let sa_exists = run_blocking!({ + let mut conn = pool2.get()?; + service_accounts_table + .filter(sa_uid.eq(&client_id)) + .count() + .get_result::(&mut conn) + .map_err(|e| { + log::error!("[ISSUE TOKEN] DB error: {}", e); + ABError::InternalServerError("Database error".to_string()) + }) + })?; + + if sa_exists > 0 { + req.client_secret.clone() + } else { + log::error!( + "[ISSUE TOKEN] No credentials found for client_id: {}", + client_id + ); + return Err(ABError::Unauthorized("Invalid credentials".to_string())); + } + } + }; - let status = response.status(); - log::info!("[ISSUE TOKEN] Keycloak response status: {}", status); + log::info!("[ISSUE TOKEN] Credentials resolved successfully"); - if response.status().is_success() { - let token: UserToken = response.json().await.map_err(|e| { - log::error!("[ISSUE TOKEN] Failed to parse Keycloak response: {}", e); - ABError::InternalServerError("Authentication service error".to_string()) - })?; - log::info!("[ISSUE TOKEN] Token issued successfully"); - Ok(Json(token)) - } else { - let error_body = response - .text() - .await - .unwrap_or_else(|_| "Unable to read error body".to_string()); - log::error!( - "[ISSUE TOKEN] Keycloak authentication failed with status {}: {}", - status, - error_body - ); - Err(ABError::Unauthorized("Invalid credentials".to_string())) - } + let token = state + .authn_provider + .refresh_access_token(state.get_ref(), &refresh_token) + .await?; + log::info!("[ISSUE TOKEN] Token issued successfully"); + Ok(Json(token)) } diff --git a/airborne_server/src/types.rs b/airborne_server/src/types.rs index b90b2ca2..911857f2 100644 --- a/airborne_server/src/types.rs +++ b/airborne_server/src/types.rs @@ -23,17 +23,20 @@ use http::{HeaderName, HeaderValue}; use keycloak::KeycloakError; use log::error; use serde::{Deserialize, Deserializer, Serialize}; +use std::sync::Arc; use superposition_sdk::Client; use thiserror::Error; use crate::{ - organisation::{application::types::OrgAppError, types::OrgError}, + provider::{authn::AuthNProvider, authz::AuthZProvider}, utils::{db, migrations::SuperpositionDefaultConfig}, }; #[derive(Clone)] pub struct AppState { pub env: Environment, + pub authn_provider: Arc, + pub authz_provider: Arc, pub db_pool: db::DbPool, pub s3_client: aws_sdk_s3::Client, pub cf_client: aws_sdk_cloudfront::Client, @@ -46,20 +49,121 @@ pub struct AppState { #[derive(Clone, Debug)] pub struct Environment { pub public_url: String, + pub authn_issuer_url: String, + pub authn_external_issuer_url: String, + pub authn_client_id: String, + pub authn_client_secret: String, + pub authn_clock_skew_secs: u64, + pub auth_admin_client_id: String, + pub auth_admin_client_secret: String, + pub auth_admin_token_url: String, + pub auth_admin_audience: Option, + pub auth_admin_scopes: Option, pub keycloak_url: String, - pub keycloak_external_url: String, - pub keycloak_public_key: String, - pub client_id: String, - pub secret: String, pub realm: String, pub bucket_name: String, pub superposition_org_id: String, - pub enable_google_signin: bool, + pub enabled_oidc_idps: Vec, pub organisation_creation_disabled: bool, pub google_spreadsheet_id: String, pub cloudfront_distribution_id: String, pub default_configs: Vec, } + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum AuthnProviderKind { + Keycloak, + Oidc, + Okta, + Auth0, +} + +impl AuthnProviderKind { + pub fn as_str(self) -> &'static str { + match self { + AuthnProviderKind::Keycloak => "keycloak", + AuthnProviderKind::Oidc => "oidc", + AuthnProviderKind::Okta => "okta", + AuthnProviderKind::Auth0 => "auth0", + } + } +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum AuthzProviderKind { + Casbin, +} + +impl AuthzProviderKind { + pub fn as_str(self) -> &'static str { + match self { + AuthzProviderKind::Casbin => "casbin", + } + } +} + +impl std::str::FromStr for AuthzProviderKind { + type Err = String; + + fn from_str(value: &str) -> std::result::Result { + match value.to_ascii_lowercase().as_str() { + "casbin" => Ok(AuthzProviderKind::Casbin), + _ => Err(format!( + "Unsupported AUTHZ_PROVIDER '{}'. Expected one of: casbin", + value + )), + } + } +} + +impl std::str::FromStr for AuthnProviderKind { + type Err = String; + + fn from_str(value: &str) -> std::result::Result { + match value.to_ascii_lowercase().as_str() { + "keycloak" => Ok(AuthnProviderKind::Keycloak), + "oidc" => Ok(AuthnProviderKind::Oidc), + "okta" => Ok(AuthnProviderKind::Okta), + "auth0" => Ok(AuthnProviderKind::Auth0), + _ => Err(format!( + "Unsupported AUTHN_PROVIDER '{}'. Expected one of: keycloak, oidc, okta, auth0", + value + )), + } + } +} + +#[cfg(test)] +mod authn_provider_tests { + use super::{AuthnProviderKind, AuthzProviderKind}; + use std::str::FromStr; + + #[test] + fn parses_provider_names_case_insensitively() { + assert_eq!( + AuthnProviderKind::from_str("keycloak").expect("keycloak should parse"), + AuthnProviderKind::Keycloak + ); + assert_eq!( + AuthnProviderKind::from_str("OIDC").expect("oidc should parse"), + AuthnProviderKind::Oidc + ); + assert_eq!( + AuthnProviderKind::from_str("Okta").expect("okta should parse"), + AuthnProviderKind::Okta + ); + assert_eq!( + AuthnProviderKind::from_str("AUTH0").expect("auth0 should parse"), + AuthnProviderKind::Auth0 + ); + assert_eq!( + AuthzProviderKind::from_str("CASBIN").expect("casbin should parse"), + AuthzProviderKind::Casbin + ); + } +} pub trait AppError: std::error::Error + Send + Sync + 'static { fn code(&self) -> &'static str; fn status_code(&self) -> StatusCode; @@ -76,12 +180,6 @@ pub struct ErrorBody { #[derive(Debug, Error)] pub enum ABError { - #[error(transparent)] - OrgAppError(#[from] OrgAppError), - - #[error(transparent)] - OrgError(#[from] OrgError), - #[error("{0}")] NotFound(String), @@ -190,8 +288,6 @@ impl AppError for ABError { ABError::BadRequest(_) => ABErrorCodes::BadRequest.label(), ABError::Forbidden(_) => ABErrorCodes::Forbidden.label(), ABError::R2D2Error(_) => ABErrorCodes::InternalServerError.label(), - ABError::OrgAppError(org_app_error) => org_app_error.code(), - ABError::OrgError(org_error) => org_error.code(), } } @@ -203,8 +299,6 @@ impl AppError for ABError { ABError::BadRequest(_) => StatusCode::BAD_REQUEST, ABError::Forbidden(_) => StatusCode::FORBIDDEN, ABError::R2D2Error(_) => StatusCode::INTERNAL_SERVER_ERROR, - ABError::OrgAppError(org_app_error) => org_app_error.status_code(), - ABError::OrgError(org_error) => org_error.status_code(), } } diff --git a/airborne_server/src/user.rs b/airborne_server/src/user.rs index d442383d..b713c0a0 100644 --- a/airborne_server/src/user.rs +++ b/airborne_server/src/user.rs @@ -15,23 +15,18 @@ use crate::{ middleware::auth::{Auth, AuthResponse}, organisation::{application::types::Application, Organisation}, + provider::authn::AuthnTokenClaims, types as airborne_types, types::{ABError, AppState}, user::types::*, - utils::keycloak::{decode_jwt_token, get_token}, }; use actix_web::{ get, post, web::{self, Json, Query}, HttpRequest, Scope, }; -use keycloak::{ - types::{CredentialRepresentation, UserRepresentation}, - KeycloakAdmin, -}; use log::info; use serde_json::json; -use std::collections::HashMap; pub mod types; @@ -45,6 +40,47 @@ pub fn add_routes(path: &str) -> Scope { .service(Scope::new("").wrap(Auth).service(get_user)) } +fn ensure_oidc_login_supported(state: &AppState) -> airborne_types::Result<()> { + state.authn_provider.ensure_oidc_login_enabled(state) +} + +fn ensure_password_login_supported(state: &AppState) -> airborne_types::Result<()> { + state.authn_provider.ensure_password_login_supported() +} + +fn ensure_signup_supported(state: &AppState) -> airborne_types::Result<()> { + state.authn_provider.ensure_signup_supported() +} + +async fn auth_response_from_claims( + claims: &AuthnTokenClaims, + state: web::Data, +) -> airborne_types::Result { + let subject = state.authz_provider.subject_from_claims(claims)?; + + Ok(AuthResponse { + sub: subject, + authn_sub: claims.sub.clone(), + authn_iss: claims.iss.clone(), + authn_email: claims.email.clone(), + organisation: None, + application: None, + is_super_admin: false, + username: state.authz_provider.display_name_from_claims(claims), + }) +} + +async fn auth_response_from_access_token( + access_token: &str, + state: web::Data, +) -> airborne_types::Result { + let token_data = state + .authn_provider + .verify_access_token(state.get_ref(), access_token) + .await?; + auth_response_from_claims(&token_data.claims, state).await +} + /* * User DB Schema * User Id | User | Password @@ -67,68 +103,19 @@ async fn create_user( req: Json, state: web::Data, ) -> airborne_types::Result> { + ensure_signup_supported(state.get_ref())?; + let req = req.into_inner(); info!("[CREATE_USER] Attempting to create user: {}", req.name); - // Get Keycloak Admin Token - let client = reqwest::Client::new(); - let admin_token = get_token(state.env.clone(), client) - .await - .map_err(|_| ABError::InternalServerError("Failed to get admin token".to_string()))?; - info!("[CREATE_USER] Got admin token successfully"); - - let client = reqwest::Client::new(); - let admin = KeycloakAdmin::new(&state.env.keycloak_url.clone(), admin_token, client); - let realm = state.env.realm.clone(); - - //Extract the user name and password - let req = req.into_inner(); + let token = state + .authn_provider + .signup_with_password(state.get_ref(), &req) + .await?; + let auth_response = auth_response_from_access_token(&token.access_token, state.clone()).await?; + let mut user_resp = get_user_impl(auth_response, state).await?; + user_resp.user_token = Some(token); - // See if there is an API to directly check, rather than getting all users - let users = admin - .realm_users_get( - &realm.clone(), - None, - None, - None, - Some(true), - None, - None, - None, - None, - None, - None, - None, - None, - None, - Some(req.name.clone()), - ) - .await - .map_err(|e| ABError::InternalServerError(format!("Failed to fetch users: {}", e)))?; - - info!("[CREATE_USER] Checking if user already exists"); - // Reject if user is present in db - let exists = users.iter().any(|user| user.id == Some(req.name.clone())); - if exists { - info!("[CREATE_USER] User {} already exists", req.name); - return Err(ABError::BadRequest("User already Exists".to_string())); - } - - info!("[CREATE_USER] Creating new user in Keycloak: {}", req.name); - // If not present in keycloak create a new user in keycloak - let user = UserRepresentation { - username: Some(req.name.clone()), - credentials: Some(vec![CredentialRepresentation { - value: Some(req.password.clone()), - temporary: Some(false), - type_: Some("password".to_string()), - ..Default::default() - }]), - enabled: Some(true), - ..Default::default() - }; - admin.realm_users_post(&realm, user).await?; - - login_implementation(req, state).await + Ok(user_resp) } #[post("login")] @@ -143,71 +130,16 @@ pub async fn login_implementation( req: UserCredentials, state: web::Data, ) -> airborne_types::Result> { - // Move ENVs to App State - let url = state.env.keycloak_url.clone(); - let client_id = state.env.client_id.clone(); - let secret = state.env.secret.clone(); - let realm = state.env.realm.clone(); - - let url = format!("{}/realms/{}/protocol/openid-connect/token", url, realm); - info!("[LOGIN] Attempting Keycloak login at URL: {}", url); - - // Keycloak login API - let client = reqwest::Client::new(); - let params = [ - ("client_id", client_id), - ("client_secret", secret), - ("grant_type", "password".to_string()), - ("username", req.name.clone()), - ("password", req.password.clone()), - ]; - - let response = client.post(&url).form(¶ms).send().await.map_err(|e| { - ABError::InternalServerError(format!("Failed to send login request: {}", e)) - })?; - - if response.status().is_success() { - let token: UserToken = response - .json() - .await - .map_err(|e| ABError::InternalServerError(e.to_string()))?; - let token_data = decode_jwt_token( - &token.access_token, - &state.env.keycloak_public_key, - &state.env.client_id, - )?; - let admin_token = get_token(state.env.clone(), client).await?; - let mut user_resp = get_user_impl( - AuthResponse { - is_super_admin: false, - sub: token_data.claims.sub, - admin_token, - organisation: None, - application: None, - username: req.name.clone(), - }, - state, - ) + ensure_password_login_supported(state.get_ref())?; + let token = state + .authn_provider + .login_with_password(state.get_ref(), &req) .await?; + let auth_response = auth_response_from_access_token(&token.access_token, state.clone()).await?; + let mut user_resp = get_user_impl(auth_response, state).await?; + user_resp.user_token = Some(token); - user_resp.user_token = Some(token); - return Ok(user_resp); - } - - // If response is not successful, extract error message - let error_text = response - .text() - .await - .unwrap_or_else(|_| "Unknown error".to_string()); - - let login_err: LoginFailure = serde_json::from_str(&error_text).unwrap_or(LoginFailure { - error: "Unknown error".to_string(), - error_description: error_text.clone(), - }); - - log::error!("Login failure: {:?}", login_err); - - Err(ABError::Unauthorized(login_err.error_description)) + Ok(user_resp) } #[get("")] @@ -224,109 +156,42 @@ async fn get_user_impl( state: web::Data, ) -> airborne_types::Result> { info!( - "[GET_USER] Fetching user details for ID: {}", + "[GET_USER] Fetching user details for subject: {}", authresponse.sub ); - // Get list of organisations and application in orginisation for each user - let user_id: String = authresponse.sub; - - // Get Keycloak Admin Token - let admin_token = authresponse.admin_token; - 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_users_with_user_id_groups_get(&realm, &user_id, None, None, None, None) - .await - .map_err(|e| ABError::InternalServerError(e.to_string()))?; - info!("[GET_USER] Retrieved {} groups for user", groups.len()); - - // Reject if organisation is present in db - // If not present in db create entry in db and return success - Ok(Json(parse_groups( - user_id, - authresponse.username, - groups - .iter() - .filter_map(|g| g.path.clone()) // Filters out None values - .collect(), - ))) -} - -fn parse_groups(user_id: String, username: String, groups: Vec) -> User { - let mut organisations: HashMap = HashMap::new(); - - for group in groups.iter() { - info!("[PARSE_GROUPS] Processing group: {}", group); - let path = group.trim_matches('/'); // Remove leading/trailing slashes - let parts: Vec<&str> = path.split('/').collect(); - - if path == "super_admin" { - continue; - } + let summary = state + .authz_provider + .get_user_access_summary(state.get_ref(), &authresponse.sub) + .await?; - let access = parts.last().unwrap().to_string(); + let mut organisations = summary + .organisations + .into_iter() + .map(|org| Organisation { + name: org.name, + applications: org + .applications + .into_iter() + .map(|app| Application { + application: app.application, + organisation: app.organisation, + access: app.access, + }) + .collect(), + access: org.access, + }) + .collect::>(); - let organisation_name = parts[0].to_string(); - let application_name = if parts.len() == 3 { - Some(parts[1].to_string()) - } else { - None - }; + organisations.sort_by(|left, right| left.name.cmp(&right.name)); - if let Some(app_name) = application_name { - // Handle application-level access - let organisation = - organisations - .entry(organisation_name.clone()) - .or_insert(Organisation { - name: organisation_name.clone(), - applications: vec![], - access: vec![], - }); - - let app = organisation - .applications - .iter_mut() - .find(|app| app.application == app_name); - - if let Some(app) = app { - app.access.push(access); - } else { - organisation.applications.push(Application { - application: app_name, - organisation: organisation_name.clone(), - access: vec![access], - }); - } - } else { - // Handle organisation-level access - let organisation = - organisations - .entry(organisation_name.clone()) - .or_insert(Organisation { - name: organisation_name.clone(), - applications: vec![], - access: vec![], - }); - - organisation.access.push(access); - } - } - let is_super_admin = groups.contains(&"/super_admin".to_string()); - info!( - "[PARSE_GROUPS] Finished parsing. Found {} organisations", - organisations.len() - ); - User { - user_id, - username, - is_super_admin, - organisations: organisations.into_values().collect(), + Ok(Json(User { + user_id: summary.subject, + username: authresponse.username, + is_super_admin: summary.is_super_admin, + organisations, user_token: None, - } + })) } #[get("oauth/url")] @@ -335,101 +200,33 @@ async fn get_oauth_url( query: Query, state: web::Data, ) -> airborne_types::Result> { - // Use external URL directly from config - if !state.env.enable_google_signin { - return Err(ABError::BadRequest( - "Google Sign-in is disabled".to_string(), - )); - } - let keycloak_url = &state.env.keycloak_external_url; - let realm = &state.env.realm; - let client_id = &state.env.client_id; - - let base_url = state.env.public_url.clone(); - let redirect_uri = format!("{}/oauth/callback", base_url); - - let offline = query.offline.unwrap_or(false); - let scope = if offline { - "openid offline_access" - } else { - "openid" - }; - - let oauth_state = "oauth_login_state".to_string(); - - let auth_url = format!( - "{}/realms/{}/protocol/openid-connect/auth?client_id={}&response_type=code&scope={}&redirect_uri={}&kc_idp_hint=google&state={}", - keycloak_url, - realm, - client_id, - urlencoding::encode(scope), - urlencoding::encode(&redirect_uri), - oauth_state - ); - - info!("[OAUTH_URL] Generated OAuth URL: {}", auth_url); - info!("[OAUTH_URL] Base URL from request: {}", base_url); - info!("[OAUTH_URL] Redirect URI: {}", redirect_uri); + ensure_oidc_login_supported(state.get_ref())?; + let oauth_url = state + .authn_provider + .get_oauth_url( + state.get_ref(), + query.offline.unwrap_or(false), + query.idp.as_deref(), + ) + .await?; + info!("[OAUTH_URL] Generated OAuth URL"); Ok(Json(json!({ - "auth_url": auth_url, - "state": oauth_state + "auth_url": oauth_url.auth_url, + "state": oauth_url.state }))) } pub async fn exchange_code_for_token( code: &str, + oauth_state: Option<&str>, _req: &HttpRequest, state: &web::Data, ) -> airborne_types::Result { - // Use internal URL for backend-to-backend communication - let url = format!( - "{}/realms/{}/protocol/openid-connect/token", - state.env.keycloak_url, // Use internal URL for token exchange - state.env.realm - ); - - // Get redirect URI from request - let base_url = state.env.public_url.clone(); - let redirect_uri = format!("{}/oauth/callback", base_url); - - let params = [ - ("client_id", state.env.client_id.clone()), - ("client_secret", state.env.secret.clone()), - ("grant_type", "authorization_code".to_string()), - ("code", code.to_string()), - ("redirect_uri", redirect_uri.to_string()), - ]; - - info!("[EXCHANGE_CODE] Exchanging code for token"); - info!("[EXCHANGE_CODE] URL: {}", url); - info!("[EXCHANGE_CODE] Redirect URI: {}", redirect_uri); - - let client = reqwest::Client::new(); - let response = client - .post(&url) - .header("Content-Type", "application/x-www-form-urlencoded") - .form(¶ms) - .send() + state + .authn_provider + .exchange_code_for_token(state.get_ref(), code, oauth_state) .await - .map_err(|e| { - info!("[EXCHANGE_CODE] Request failed: {}", e); - ABError::InternalServerError(e.to_string()) - })?; - - if response.status().is_success() { - response.json::().await.map_err(|e| { - info!("[EXCHANGE_CODE] Failed to parse token response: {}", e); - ABError::InternalServerError(format!("Failed to parse token response: {}", e)) - }) - } else { - let error_text = response.text().await.unwrap_or_default(); - info!("[EXCHANGE_CODE] Token exchange failed: {}", error_text); - Err(ABError::Unauthorized(format!( - "Token exchange failed: {}", - error_text - ))) - } } #[post("oauth/login")] @@ -438,57 +235,33 @@ async fn oauth_login( json_req: Json, state: web::Data, ) -> airborne_types::Result> { - if !state.env.enable_google_signin { - return Err(ABError::BadRequest( - "Google Sign-in is disabled".to_string(), - )); - } + ensure_oidc_login_supported(state.get_ref())?; info!("[OAUTH_LOGIN] Processing OAuth login with code"); let oauth_req = json_req.into_inner(); - let token_response = match exchange_code_for_token(&oauth_req.code, &req, &state).await { - Ok(response) => response, - Err(e) => { - info!("[OAUTH_LOGIN] Token exchange failed: {:?}", e); - return Err(e); - } - }; + let token_response = + match exchange_code_for_token(&oauth_req.code, oauth_req.state.as_deref(), &req, &state) + .await + { + Ok(response) => response, + Err(e) => { + info!("[OAUTH_LOGIN] Token exchange failed: {:?}", e); + return Err(e); + } + }; // Decode the access token to get user info - let token_data = decode_jwt_token( - &token_response.access_token, - &state.env.keycloak_public_key, - &state.env.client_id, - ) - .map_err(|e| { - info!("[OAUTH_LOGIN] Token decode failed: {:?}", e); - ABError::BadRequest("Invalid token".to_string()) - })?; - - // Get admin token for user operations - let client = reqwest::Client::new(); - let admin_token = get_token(state.env.clone(), client).await.map_err(|e| { - info!("[OAUTH_LOGIN] Failed to get admin token: {}", e); - ABError::InternalServerError(format!("Failed to get admin token: {}", e)) - })?; - - let mut user_resp = get_user_impl( - AuthResponse { - sub: token_data.claims.sub.clone(), - is_super_admin: false, - admin_token, - organisation: None, - application: None, - username: token_data - .claims - .preferred_username - .clone() - .ok_or_else(|| ABError::Unauthorized("No email in token".to_string()))?, - }, - state, - ) - .await?; + let token_data = state + .authn_provider + .verify_access_token(state.get_ref(), &token_response.access_token) + .await + .map_err(|e| { + info!("[OAUTH_LOGIN] Token decode failed: {:?}", e); + ABError::BadRequest("Invalid token".to_string()) + })?; + let auth_response = auth_response_from_claims(&token_data.claims, state.clone()).await?; + let mut user_resp = get_user_impl(auth_response, state).await?; user_resp.user_token = Some(UserToken { access_token: token_response.access_token, @@ -507,56 +280,32 @@ async fn oauth_signup( json_req: Json, state: web::Data, ) -> airborne_types::Result> { - if !state.env.enable_google_signin { - return Err(ABError::BadRequest( - "Google Sign-in is disabled".to_string(), - )); - } + ensure_oidc_login_supported(state.get_ref())?; + ensure_signup_supported(state.get_ref())?; info!("[OAUTH_SIGNUP] Processing OAuth signup with code"); let oauth_req = json_req.into_inner(); // Exchange authorization code for tokens (same as login) - let token_response = exchange_code_for_token(&oauth_req.code, &req, &state).await?; + let token_response = + exchange_code_for_token(&oauth_req.code, oauth_req.state.as_deref(), &req, &state).await?; // Decode the access token to get user info - let token_data = decode_jwt_token( - &token_response.access_token, - &state.env.keycloak_public_key, - &state.env.client_id, - ) - .map_err(|_| ABError::Unauthorized("Invalid token".to_string()))?; + let token_data = state + .authn_provider + .verify_access_token(state.get_ref(), &token_response.access_token) + .await + .map_err(|_| ABError::Unauthorized("Invalid token".to_string()))?; info!( - "[OAUTH_SIGNUP] Successfully authenticated user via Google OAuth: {}", + "[OAUTH_SIGNUP] Successfully authenticated user via OIDC OAuth: {}", token_data.claims.sub ); - // Get admin token for user operations - let client = reqwest::Client::new(); - let admin_token = get_token(state.env.clone(), client).await.map_err(|e| { - info!("[OAUTH_SIGNUP] Failed to get admin token: {}", e); - ABError::InternalServerError(format!("Failed to get admin token: {}", e)) - })?; - - // For signup, we process it the same way as login since Keycloak handles user creation - // The user account is automatically created in Keycloak when they sign in with Google - let mut user_resp = get_user_impl( - AuthResponse { - is_super_admin: false, - sub: token_data.claims.sub, - admin_token, - organisation: None, - application: None, - username: token_data - .claims - .preferred_username - .clone() - .ok_or_else(|| ABError::Unauthorized("No email in token".to_string()))?, - }, - state, - ) - .await?; + // For signup, v1 keeps provider-specific behavior and this endpoint is enabled only on + // providers that support signup. + let auth_response = auth_response_from_claims(&token_data.claims, state.clone()).await?; + let mut user_resp = get_user_impl(auth_response, state).await?; user_resp.user_token = Some(UserToken { access_token: token_response.access_token, diff --git a/airborne_server/src/user/types.rs b/airborne_server/src/user/types.rs index cb682160..5828e1ad 100644 --- a/airborne_server/src/user/types.rs +++ b/airborne_server/src/user/types.rs @@ -44,6 +44,9 @@ pub struct OAuthRequest { pub struct UserCredentials { pub name: String, pub password: String, + pub first_name: Option, + pub last_name: Option, + pub email: Option, } #[derive(Serialize, Deserialize, Debug)] @@ -64,4 +67,5 @@ pub struct LoginFailure { #[derive(Deserialize)] pub struct OAuthQuery { pub offline: Option, + pub idp: Option, } diff --git a/airborne_server/src/utils.rs b/airborne_server/src/utils.rs index e75ad877..1fca6e35 100644 --- a/airborne_server/src/utils.rs +++ b/airborne_server/src/utils.rs @@ -22,7 +22,6 @@ pub mod kms; pub mod migrations; pub mod s3; pub mod semver; -pub mod transaction_manager; pub mod workspace; use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; diff --git a/airborne_server/src/utils/db/models.rs b/airborne_server/src/utils/db/models.rs index 150d7eba..d83b5346 100644 --- a/airborne_server/src/utils/db/models.rs +++ b/airborne_server/src/utils/db/models.rs @@ -4,8 +4,8 @@ use diesel::prelude::*; use serde::{Deserialize, Serialize}; use crate::utils::db::schema::hyperotaserver::{ - builds, cleanup_outbox, configs, files, packages, packages_v2, release_views, releases, - user_credentials, workspace_names, + authz_memberships, authz_role_bindings, builds, cleanup_outbox, configs, files, packages, + packages_v2, release_views, releases, service_accounts, user_credentials, workspace_names, }; use crate::utils::semver::SemVer; @@ -192,3 +192,59 @@ pub struct UserCredentialsEntry { pub application: String, pub created_at: DateTime, } + +#[derive(Queryable, Insertable, Debug, Selectable, Clone)] +#[diesel(table_name = authz_memberships)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct AuthzMembershipEntry { + pub subject: String, + pub scope: String, + pub organisation: String, + pub application: String, + pub role_key: String, + pub role_level: i32, + pub updated_at: DateTime, +} + +#[derive(Insertable, Debug, Clone)] +#[diesel(table_name = authz_memberships)] +pub struct NewAuthzMembershipEntry { + pub subject: String, + pub scope: String, + pub organisation: String, + pub application: String, + pub role_key: String, + pub role_level: i32, +} + +#[derive(Queryable, Insertable, Debug, Selectable, Clone)] +#[diesel(table_name = authz_role_bindings)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct AuthzRoleBindingEntry { + pub scope: String, + pub role_key: String, + pub resource: String, + pub action: String, + pub created_at: DateTime, +} + +#[derive(Insertable, Debug, Clone)] +#[diesel(table_name = authz_role_bindings)] +pub struct NewAuthzRoleBindingEntry { + pub scope: String, + pub role_key: String, + pub resource: String, + pub action: String, +} + +#[derive(Queryable, Insertable, Debug, Selectable, Serialize)] +#[diesel(table_name = service_accounts)] +pub struct ServiceAccountEntry { + pub client_id: uuid::Uuid, + pub name: String, + pub email: String, + pub description: String, + pub organisation: String, + pub created_by: String, + pub created_at: DateTime, +} diff --git a/airborne_server/src/utils/db/schema.rs b/airborne_server/src/utils/db/schema.rs index 77374026..6b5ad2c3 100644 --- a/airborne_server/src/utils/db/schema.rs +++ b/airborne_server/src/utils/db/schema.rs @@ -1,6 +1,28 @@ // @generated automatically by Diesel CLI. pub mod hyperotaserver { + diesel::table! { + hyperotaserver.authz_memberships (subject, scope, organisation, application) { + subject -> Text, + scope -> Text, + organisation -> Text, + application -> Text, + role_key -> Text, + role_level -> Int4, + updated_at -> Timestamptz, + } + } + + diesel::table! { + hyperotaserver.authz_role_bindings (scope, role_key, resource, action) { + scope -> Text, + role_key -> Text, + resource -> Text, + action -> Text, + created_at -> Timestamptz, + } + } + diesel::table! { hyperotaserver.cleanup_outbox (transaction_id) { transaction_id -> Text, @@ -126,7 +148,21 @@ pub mod hyperotaserver { } } + diesel::table! { + hyperotaserver.service_accounts (client_id) { + client_id -> Uuid, + name -> Text, + email -> Text, + description -> Text, + organisation -> Text, + created_by -> Text, + created_at -> Timestamptz, + } + } + diesel::allow_tables_to_appear_in_same_query!( + authz_memberships, + authz_role_bindings, cleanup_outbox, builds, configs, @@ -135,6 +171,7 @@ pub mod hyperotaserver { packages_v2, release_views, releases, + service_accounts, user_credentials, workspace_names, ); diff --git a/airborne_server/src/utils/keycloak.rs b/airborne_server/src/utils/keycloak.rs index 3f787be1..c7b60e1d 100644 --- a/airborne_server/src/utils/keycloak.rs +++ b/airborne_server/src/utils/keycloak.rs @@ -1,72 +1,95 @@ use crate::{ - middleware::auth::AuthResponse, types as airborne_types, - types::{ABError, AppState, Environment}, -}; -use actix_web::{web, HttpMessage, HttpRequest}; -use jsonwebtoken::{decode, Algorithm, DecodingKey, TokenData, Validation}; -use keycloak::{ - self, - types::{GroupRepresentation, UserRepresentation}, - KeycloakAdmin, KeycloakAdminToken, KeycloakServiceAccountAdminTokenRetriever, + types::{ABError, Environment}, }; +use keycloak::{self, types::UserRepresentation, KeycloakAdmin, KeycloakAdminToken}; use reqwest::Client; -use serde::{Deserialize, Serialize}; - -#[derive(Serialize, Deserialize, Debug)] -pub struct Claims { - pub sub: String, // User ID - pub preferred_username: Option, // Name - pub email: Option, - pub realm_access: Option, -} +use serde::Deserialize; -#[derive(Serialize, Deserialize, Debug)] -pub struct Roles { - pub roles: Vec, // Roles assigned to the user +#[derive(Debug, Deserialize)] +struct OAuthErrorResponse { + error: Option, + error_description: Option, } pub async fn get_token( env: Environment, client: Client, ) -> airborne_types::Result { - // Move ENVs to App State - let url = env.keycloak_url.clone(); - let client_id = env.client_id.clone(); - let secret = env.secret.clone(); - let realm = env.realm.clone(); + if env.auth_admin_client_id.trim().is_empty() { + return Err(ABError::InternalServerError( + "AUTH_ADMIN_CLIENT_ID must be configured for admin API calls".to_string(), + )); + } + if env.auth_admin_client_secret.trim().is_empty() { + return Err(ABError::InternalServerError( + "AUTH_ADMIN_CLIENT_SECRET must be configured for admin API calls".to_string(), + )); + } + if env.auth_admin_token_url.trim().is_empty() { + return Err(ABError::InternalServerError( + "AUTH_ADMIN_TOKEN_URL must be configured for admin API calls".to_string(), + )); + } - // See if keycloak admin can be in app state as well - let token_retriever = KeycloakServiceAccountAdminTokenRetriever::create_with_custom_realm( - &client_id, &secret, &realm, client, - ); + let mut params: Vec<(&str, String)> = vec![ + ("grant_type", "client_credentials".to_string()), + ("client_id", env.auth_admin_client_id.clone()), + ("client_secret", env.auth_admin_client_secret.clone()), + ]; + if let Some(audience) = env.auth_admin_audience.clone() { + if !audience.trim().is_empty() { + params.push(("audience", audience)); + } + } + if let Some(scopes) = env.auth_admin_scopes.clone() { + if !scopes.trim().is_empty() { + params.push(("scope", scopes)); + } + } - // Fetch client level admin token - Ok(token_retriever.acquire(&url).await?) -} + let response = client + .post(&env.auth_admin_token_url) + .form(¶ms) + .send() + .await + .map_err(|error| { + ABError::InternalServerError(format!("Failed to request admin access token: {error}")) + })?; -pub fn decode_jwt_token( - token: &str, - public_key: &str, - audience: &str, -) -> airborne_types::Result> { - let key = DecodingKey::from_rsa_pem(public_key.as_bytes())?; - let mut validation = Validation::new(Algorithm::RS256); - validation.set_audience(&[audience]); - Ok(decode::(token, &key, &validation)?) + if !response.status().is_success() { + let error_text = response.text().await.unwrap_or_default(); + let parsed_error = serde_json::from_str::(&error_text).ok(); + let message = parsed_error + .and_then(|parsed| parsed.error_description.or(parsed.error)) + .unwrap_or(error_text); + return Err(ABError::Unauthorized(format!( + "Failed to get admin access token: {message}" + ))); + } + + response + .json::() + .await + .map_err(|error| { + ABError::InternalServerError(format!( + "Failed to parse admin access token response: {error}" + )) + }) } -pub async fn find_user_by_username( +async fn search_users( admin: &KeycloakAdmin, realm: &str, - username: &str, -) -> airborne_types::Result> { - let users = admin + search_term: &str, +) -> airborne_types::Result> { + admin .realm_users_get( realm, None, None, None, + Some(true), None, None, None, @@ -76,81 +99,24 @@ pub async fn find_user_by_username( None, None, None, - None, - Some(username.to_string()), + Some(search_term.to_string()), ) .await .map_err(|e| { - ABError::InternalServerError(format!("Failed to find user by username: {}", e)) - })?; - - if users.is_empty() { - return Ok(None); - } - - Ok(Some(users[0].clone())) + ABError::InternalServerError(format!("Failed to search user in keycloak: {}", e)) + }) } -pub async fn prepare_user_action( - req: &HttpRequest, - state: web::Data, -) -> airborne_types::Result<(KeycloakAdmin, String)> { - let auth_response = req - .extensions() - .get::() - .cloned() - .ok_or(ABError::Unauthorized("Token Parse Failed".to_string()))?; - - let admin_token = auth_response.admin_token.clone(); - let client = reqwest::Client::new(); - let admin = KeycloakAdmin::new(&state.env.keycloak_url.clone(), admin_token, client); - let realm = state.env.realm.clone(); - - Ok((admin, realm)) -} - -pub async fn find_org_group( - admin: &KeycloakAdmin, - realm: &str, - org_name: &str, -) -> airborne_types::Result> { - let groups = admin - .realm_groups_get( - realm, - None, - Some(true), - None, - None, - None, - None, - Some(org_name.to_string()), - ) - .await?; - - if groups.is_empty() { - return Ok(None); - } - - Ok(Some(groups[0].clone())) -} - -pub async fn find_role_subgroup( +pub async fn find_user_by_username( admin: &KeycloakAdmin, realm: &str, - group_id: &str, - role: &str, -) -> airborne_types::Result> { - let subgroups = admin - .realm_groups_with_group_id_children_get(realm, group_id, None, None, None, None, None) - .await?; - - for group in subgroups { - if let Some(name) = &group.name { - if name == role { - return Ok(Some(group)); - } - } - } - - Ok(None) + username: &str, +) -> airborne_types::Result> { + let users = search_users(admin, realm, username).await?; + Ok(users.into_iter().find(|user| { + user.username + .as_ref() + .map(|candidate| candidate.eq_ignore_ascii_case(username)) + .unwrap_or(false) + })) } diff --git a/airborne_server/src/utils/transaction_manager.rs b/airborne_server/src/utils/transaction_manager.rs deleted file mode 100644 index b52b768e..00000000 --- a/airborne_server/src/utils/transaction_manager.rs +++ /dev/null @@ -1,481 +0,0 @@ -// Copyright 2025 Juspay Technologies -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use actix_web::web; -use chrono::{DateTime, Utc}; -use diesel::RunQueryDsl; -use keycloak::KeycloakAdmin; -use log::{debug, error, info, warn}; -use serde::{Deserialize, Serialize}; -use std::sync::{Arc, Mutex}; -use uuid::Uuid; - -use crate::run_blocking; -use crate::types as airborne_types; -use crate::types::{ABError, AppState}; -use crate::utils::db::models::CleanupOutboxEntry; -use crate::utils::db::schema::hyperotaserver::cleanup_outbox::dsl::cleanup_outbox; - -/// Represents a resource in Keycloak that needs to be tracked for transaction management -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct KeycloakResource { - pub resource_type: String, - pub resource_id: String, -} - -/// Represents the state of a distributed transaction across multiple systems -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TransactionState { - /// Unique identifier for the transaction - pub transaction_id: String, - /// Name of the entity being created/modified - pub entity_name: String, - /// Type of entity (e.g., "organization", "application") - pub entity_type: String, - /// IDs of Keycloak resources created as part of this transaction - pub keycloak_resource_ids: Vec, - /// ID of the Superposition resource if created - pub superposition_resource_id: Option, - /// Whether the database insert was completed - pub database_inserted: bool, - /// Timestamp when the transaction was started - pub created_at: DateTime, -} - -/// Manager for distributed transactions across multiple systems -pub struct TransactionManager { - state: Arc>, -} - -impl TransactionManager { - pub fn new(entity_name: &str, entity_type: &str) -> Self { - let transaction_id = Uuid::new_v4().to_string(); - TransactionManager { - state: Arc::new(Mutex::new(TransactionState { - transaction_id, - entity_name: entity_name.to_string(), - entity_type: entity_type.to_string(), - keycloak_resource_ids: Vec::new(), - superposition_resource_id: None, - database_inserted: false, - created_at: Utc::now(), - })), - } - } - - pub fn add_keycloak_resource(&self, resource_type: &str, resource_id: &str) { - let mut state = self.state.lock().unwrap(); - state.keycloak_resource_ids.push(KeycloakResource { - resource_type: resource_type.to_string(), - resource_id: resource_id.to_string(), - }); - } - - pub fn add_keycloak_group(&self, group_id: &str) { - self.add_keycloak_resource("group", group_id); - } - - pub fn set_superposition_resource(&self, resource_id: &str) { - let mut state = self.state.lock().unwrap(); - state.superposition_resource_id = Some(resource_id.to_string()); - } - - pub fn set_database_inserted(&self) { - let mut state = self.state.lock().unwrap(); - state.database_inserted = true; - } - - pub fn get_state(&self) -> TransactionState { - self.state.lock().unwrap().clone() - } - - pub fn is_complete(&self) -> bool { - let state = self.state.lock().unwrap(); - state.database_inserted && state.superposition_resource_id.is_some() - } - - pub async fn handle_rollback_if_needed( - &self, - admin: &KeycloakAdmin, - realm: &str, - app_state: &web::Data, - ) -> airborne_types::Result { - if self.is_complete() { - return Ok(false); - } - - let tx_state = self.get_state(); - - info!( - "Rolling back incomplete transaction {} for {} {}", - tx_state.transaction_id, tx_state.entity_type, tx_state.entity_name - ); - - // Keycloak cleanp - for resource in tx_state.keycloak_resource_ids.iter().rev() { - match resource.resource_type.as_str() { - "group" => { - if let Err(e) = admin - .realm_groups_with_group_id_delete(realm, &resource.resource_id) - .await - { - warn!( - "Failed to delete Keycloak group {}: {}", - resource.resource_id, e - ); - } - } - // Add other resource types as needed - _ => warn!("Unknown Keycloak resource type: {}", resource.resource_type), - } - } - - // Superposition cleanup - if let Some(sp_id) = &tx_state.superposition_resource_id { - if let Err(e) = cleanup_superposition_resource(sp_id, &tx_state.entity_type).await { - warn!("Failed to clean up Superposition resource: {}", e); - // Record the failed cleanup for later reconciliation - if let Err(e) = record_failed_cleanup(app_state, &tx_state).await { - error!("CRITICAL: Failed to record cleanup job: {}", e); - } - } - } - - Ok(true) - } -} - -/// Clean up a Superposition resource -async fn cleanup_superposition_resource( - resource_id: &str, - resource_type: &str, -) -> Result<(), Box> { - // Implement based on resource type - match resource_type { - "organization_create" => { - // Note: If the Superposition SDK doesn't provide a delete organization method, - // log this as something that needs manual cleanup - warn!( - "Superposition resource {} of type {} requires manual cleanup. SDK does not support deletion.", - resource_id, resource_type - ); - // Return Ok since we can't automatically clean this up - Ok(()) - } - "organization_user" | "organization_user_update" | "organization_user_remove" => { - // User operations don't have their own Superposition resources - // so there's nothing to clean up here - Ok(()) - } - _ => { - warn!("Unknown Superposition resource type: {}", resource_type); - Ok(()) - } - } -} - -/// Record a failed cleanup to the outbox for later reconciliation -pub async fn record_failed_cleanup( - app_state: &web::Data, - tx_state: &TransactionState, -) -> airborne_types::Result<()> { - let state_json = serde_json::to_value(tx_state).map_err(|e| { - ABError::InternalServerError(format!("Failed to serialize transaction state: {}", e)) - })?; - - let outbox_entry = CleanupOutboxEntry { - transaction_id: tx_state.transaction_id.clone(), - entity_name: tx_state.entity_name.clone(), - entity_type: tx_state.entity_type.clone(), - state: state_json, - created_at: tx_state.created_at, - attempts: 0, - last_attempt: None, - }; - - let pool = app_state.db_pool.clone(); - let _ = run_blocking!({ - let mut conn = pool.get()?; - - diesel::insert_into(cleanup_outbox) - .values(&outbox_entry) - .execute(&mut conn)?; - Ok(()) - }); - - info!( - "Recorded cleanup job for transaction {} to outbox", - tx_state.transaction_id - ); - - Ok(()) -} - -/// Process the cleanup outbox to retry failed cleanups -pub async fn process_cleanup_outbox( - app_state: &web::Data, -) -> Result<(), Box> { - use crate::utils::db::schema::hyperotaserver::cleanup_outbox::dsl::*; - use chrono::Utc; - use diesel::prelude::*; - - // Constants for cleanup job configuration - const MAX_ATTEMPTS: i32 = 5; - const MAX_JOBS_PER_RUN: i64 = 10; - const MIN_RETRY_INTERVAL_SECS: i64 = 300; // 5 minutes - - info!("Starting cleanup outbox processing"); - - // Get the current time - let current_time = Utc::now(); - let min_retry_time = current_time - chrono::Duration::seconds(MIN_RETRY_INTERVAL_SECS); - - // Query for jobs that need processing: - // 1. Less than MAX_ATTEMPTS - // 2. Either never attempted (last_attempt is null) or last attempted more than MIN_RETRY_INTERVAL_SECS ago - // 3. Limit to MAX_JOBS_PER_RUN to avoid overloading the system - let pool = app_state.db_pool.clone(); - let pending_jobs = run_blocking!({ - let mut conn = pool.get()?; - - let pending_jobs: Vec = cleanup_outbox - .filter(attempts.lt(MAX_ATTEMPTS)) - .filter(last_attempt.is_null().or(last_attempt.lt(min_retry_time))) - .order_by(created_at.asc()) - .limit(MAX_JOBS_PER_RUN) - .load::(&mut conn)?; - - Ok(pending_jobs) - })?; - - if pending_jobs.is_empty() { - debug!("No pending cleanup jobs found"); - return Ok(()); - } - - info!( - "Found {} pending cleanup jobs to process", - pending_jobs.len() - ); - - // Process each job - for job in pending_jobs { - info!( - "Processing cleanup job {} for {} {}", - job.transaction_id, job.entity_type, job.entity_name - ); - - // Deserialize the transaction state - let tx_state: Result = serde_json::from_value(job.state.clone()); - - if let Err(e) = tx_state { - error!( - "Failed to deserialize transaction state for job {}: {}", - job.transaction_id, e - ); - - let pool = app_state.db_pool.clone(); - let _ = run_blocking!({ - let mut conn = pool.get()?; - - // Update the job with an incremented attempt count - diesel::update(cleanup_outbox.find(&job.transaction_id)) - .set((attempts.eq(job.attempts + 1), last_attempt.eq(current_time))) - .execute(&mut conn)?; - Ok(()) - }); - - continue; - } - - let tx_state = tx_state.unwrap(); - - // Attempt to clean up resources based on entity type - let cleanup_result = match job.entity_type.as_str() { - "organization_create" - | "organization_user" - | "organization_user_update" - | "organization_user_remove" => { - process_organization_cleanup(app_state, &tx_state).await - } - // Add more entity types as needed - _ => { - warn!("Unknown entity type for cleanup: {}", job.entity_type); - Err("Unknown entity type".into()) - } - }; - - match cleanup_result { - Ok(_) => { - info!( - "Successfully cleaned up job {} for {} {}", - job.transaction_id, job.entity_type, job.entity_name - ); - - let pool = app_state.db_pool.clone(); - let _ = run_blocking!({ - let mut conn = pool.get()?; - - // Delete the job as it's been successfully processed - diesel::delete(cleanup_outbox.find(&job.transaction_id)).execute(&mut conn)?; - Ok(()) - }); - } - Err(e) => { - warn!( - "Failed to clean up job {} for {} {}: {}", - job.transaction_id, job.entity_type, job.entity_name, e - ); - - let pool = app_state.db_pool.clone(); - let _ = run_blocking!({ - let mut conn = pool.get()?; - - // Update the job with an incremented attempt count - diesel::update(cleanup_outbox.find(&job.transaction_id)) - .set((attempts.eq(job.attempts + 1), last_attempt.eq(current_time))) - .execute(&mut conn)?; - Ok(()) - }); - } - } - } - - info!("Completed cleanup outbox processing"); - Ok(()) -} - -/// Process cleanup for organization-related transactions -async fn process_organization_cleanup( - app_state: &web::Data, - tx_state: &TransactionState, -) -> Result<(), Box> { - // Get an admin client for Keycloak using the token retriever - let client = reqwest::Client::new(); - - // Create a token retriever - let token_retriever = - keycloak::KeycloakServiceAccountAdminTokenRetriever::create_with_custom_realm( - &app_state.env.client_id, - &app_state.env.secret, - &app_state.env.realm, - client.clone(), - ); - - // Fetch client level admin token - let admin_token = token_retriever - .acquire(&app_state.env.keycloak_url) - .await - .map_err(|e| format!("Failed to acquire Keycloak admin token: {}", e))?; - - let admin = keycloak::KeycloakAdmin::new(&app_state.env.keycloak_url, admin_token, client); - let realm = app_state.env.realm.clone(); - - // Check what needs to be cleaned up - let need_keycloak_cleanup = !tx_state.keycloak_resource_ids.is_empty(); - let need_superposition_cleanup = - tx_state.superposition_resource_id.is_some() && !tx_state.database_inserted; - - // Clean up Keycloak resources if needed - if need_keycloak_cleanup { - for resource in tx_state.keycloak_resource_ids.iter().rev() { - match resource.resource_type.as_str() { - "group" => { - debug!("Cleaning up Keycloak group: {}", resource.resource_id); - if let Err(e) = admin - .realm_groups_with_group_id_delete(&realm, &resource.resource_id) - .await - { - warn!( - "Failed to delete Keycloak group {}: {}", - resource.resource_id, e - ); - } - } - "user_group_membership" => { - if let Some((user_id, group_id)) = resource.resource_id.split_once(':') { - debug!( - "Cleaning up user group membership: User {} from group {}", - user_id, group_id - ); - if let Err(e) = admin - .realm_users_with_user_id_groups_with_group_id_delete( - &realm, user_id, group_id, - ) - .await - { - warn!("Failed to remove user from group: {}", e); - } - } else { - warn!( - "Invalid user_group_membership format: {}", - resource.resource_id - ); - } - } - "user_group_removal" => { - // For removals, we need to re-add users to groups - if let Some((user_id, group_id)) = resource.resource_id.split_once(':') { - debug!( - "Restoring user group membership: User {} to group {}", - user_id, group_id - ); - if let Err(e) = admin - .realm_users_with_user_id_groups_with_group_id_put( - &realm, user_id, group_id, - ) - .await - { - warn!("Failed to add user back to group: {}", e); - } - } else { - warn!( - "Invalid user_group_removal format: {}", - resource.resource_id - ); - } - } - _ => warn!("Unknown Keycloak resource type: {}", resource.resource_type), - } - } - } - - // Clean up Superposition resources if needed - if need_superposition_cleanup { - if let Some(sp_id) = &tx_state.superposition_resource_id { - debug!("Cleaning up Superposition resource: {}", sp_id); - if let Err(e) = cleanup_superposition_resource(sp_id, &tx_state.entity_type).await { - warn!("Failed to clean up Superposition resource: {}", e); - // Return error to trigger retry - return Err(format!("Failed to clean up Superposition resource: {}", e).into()); - } - } - } - - Ok(()) -} - -pub fn start_cleanup_job(app_state: web::Data) -> tokio::task::JoinHandle<()> { - let state_clone = app_state.clone(); - tokio::spawn(async move { - loop { - if let Err(e) = process_cleanup_outbox(&state_clone).await { - error!("Error processing cleanup outbox: {}", e); - } - - // Run every minute - tokio::time::sleep(std::time::Duration::from_secs(60)).await; - } - }) -} diff --git a/memory-bank/techContext.md b/memory-bank/techContext.md index 67981c87..73b7f068 100644 --- a/memory-bank/techContext.md +++ b/memory-bank/techContext.md @@ -37,13 +37,13 @@ * Refer to `server/.env.example` and `server/superposition/.env.example` for base variables. * Key variables for Hyper OTA server (`server/scripts/.env` or `server/scripts/.env.encrypted` after KMS encryption): * `DATABASE_URL`: Connection string for Hyper OTA's PostgreSQL. - * `KEYCLOAK_URL`, `KEYCLOAK_REALM`, `KEYCLOAK_CLIENT_ID`, `KEYCLOAK_SECRET`, `KEYCLOAK_PUBLIC_KEY`. + * `AUTHN_PROVIDER`, `OIDC_ISSUER_URL`, `OIDC_EXTERNAL_ISSUER_URL`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`. * `SUPERPOSITION_URL`: URL for the Superposition service (e.g., `http://superposition:8080` from within Docker network). * `SUPERPOSITION_ORG_ID`: **(New)** ID of the Superposition organization. This will be *dynamically populated* by an initialization script (e.g., `init-superposition-org.sh`) that calls the Superposition API at startup. * `SUPERPOSITION_DEFAULT_ORG_NAME`: (New - for init script) Name of the default organization to create in Superposition if it doesn't exist (e.g., "DefaultHyperOTAOrg"). * `AWS_BUCKET`, `AWS_ENDPOINT_URL` (for LocalStack), `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`. * `PUBLIC_ENDPOINT`: Publicly accessible base URL for assets. - * Secrets like `KEYCLOAK_SECRET` and `DB_PASSWORD` (within `DATABASE_URL`) are intended to be encrypted via AWS KMS and stored in `.env.encrypted`. The `server/scripts/encrypt_env.sh` script handles this. + * Secrets like `OIDC_CLIENT_SECRET` and `DB_PASSWORD` (within `DATABASE_URL`) are intended to be encrypted via AWS KMS and stored in `.env.encrypted`. The `server/scripts/encrypt_env.sh` script handles this. * **Setup Steps:** 1. Clone the repository. 2. Ensure the `server/superposition` submodule/directory is populated.