[{"data":1,"prerenderedAt":1679},["ShallowReactive",2],{"doc:\u002Fdocs\u002Fdesign\u002Fsso-group-mapping":3,"docs-nav":1458},{"id":4,"title":5,"body":6,"description":1450,"extension":1451,"meta":1452,"navigation":667,"path":1454,"seo":1455,"stem":1456,"__hash__":1457},"docs\u002Fdocs\u002Fdesign\u002Fsso-group-mapping.md","Design note — SSO & external group mapping (OIDC)",{"type":7,"value":8,"toc":1433},"minimark",[9,13,26,29,141,166,169,174,181,185,293,306,310,372,376,386,390,395,452,473,495,499,506,874,880,884,987,991,1023,1031,1038,1051,1197,1204,1208,1285,1289,1356,1359,1363,1429],[10,11,5],"h1",{"id":12},"design-note--sso--external-group-mapping-oidc",[14,15,16,20,21,25],"p",{},[17,18,19],"strong",{},"Status: implemented and optional."," An external identity provider (Keycloak,\nMicrosoft Entra ID \u002F MSAL, Auth0, Okta, …) plugs into the gateway and its groups or\napp roles drive Tabularia's RBAC. The feature is inert until ",[22,23,24],"code",{},"OIDC__ISSUER"," is set.",[14,27,28],{},"Where it lives:",[30,31,32,45],"table",{},[33,34,35],"thead",{},[36,37,38,42],"tr",{},[39,40,41],"th",{},"piece",[39,43,44],{},"file",[46,47,48,65,89,106,127],"tbody",{},[36,49,50,54],{},[51,52,53],"td",{},"OIDC front door (discovery, PKCE, JWKS validation, group sync)",[51,55,56],{},[57,58,62],"a",{"href":59,"rel":60},"https:\u002F\u002Fgithub.com\u002FJumpAsAService\u002FTabularia\u002Fblob\u002Fmain\u002Fgateway\u002Fapp\u002Fservices\u002Fsso.py",[61],"nofollow",[22,63,64],{},"gateway\u002Fapp\u002Fservices\u002Fsso.py",[36,66,67,80],{},[51,68,69,70,73,74,73,77],{},"routes ",[22,71,72],{},"\u002Fauth\u002Fsso\u002Fconfig",", ",[22,75,76],{},"\u002Fauth\u002Fsso\u002Flogin",[22,78,79],{},"\u002Fauth\u002Fsso\u002Fcallback",[51,81,82],{},[57,83,86],{"href":84,"rel":85},"https:\u002F\u002Fgithub.com\u002FJumpAsAService\u002FTabularia\u002Fblob\u002Fmain\u002Fgateway\u002Fapp\u002Froutes\u002Fsso.py",[61],[22,87,88],{},"gateway\u002Fapp\u002Froutes\u002Fsso.py",[36,90,91,97],{},[51,92,93,96],{},[22,94,95],{},"OIDC__*"," settings + startup coherence check",[51,98,99],{},[57,100,103],{"href":101,"rel":102},"https:\u002F\u002Fgithub.com\u002FJumpAsAService\u002FTabularia\u002Fblob\u002Fmain\u002Fgateway\u002Fapp\u002Fcore\u002Fconfig.py",[61],[22,104,105],{},"gateway\u002Fapp\u002Fcore\u002Fconfig.py",[36,107,108,111],{},[51,109,110],{},"login button and callback landing page",[51,112,113,73,120],{},[57,114,117],{"href":115,"rel":116},"https:\u002F\u002Fgithub.com\u002FJumpAsAService\u002FTabularia\u002Fblob\u002Fmain\u002Ffrontend\u002Fpages\u002Flogin.vue",[61],[22,118,119],{},"frontend\u002Fpages\u002Flogin.vue",[57,121,124],{"href":122,"rel":123},"https:\u002F\u002Fgithub.com\u002FJumpAsAService\u002FTabularia\u002Fblob\u002Fmain\u002Ffrontend\u002Fpages\u002Fauth\u002Fcallback.vue",[61],[22,125,126],{},"frontend\u002Fpages\u002Fauth\u002Fcallback.vue",[36,128,129,132],{},[51,130,131],{},"tests",[51,133,134],{},[57,135,138],{"href":136,"rel":137},"https:\u002F\u002Fgithub.com\u002FJumpAsAService\u002FTabularia\u002Fblob\u002Fmain\u002Fgateway\u002Ftests\u002Ftest_sso.py",[61],[22,139,140],{},"gateway\u002Ftests\u002Ftest_sso.py",[14,142,143,144,150,151,155,156,160,161,165],{},"A runnable Keycloak example lives in ",[57,145,147],{"href":146},"\u002Fdocs\u002Fexamples\u002Fkeycloak",[22,148,149],{},"..\u002Fexamples\u002Fkeycloak\u002F","; the\nstep-by-step guide for an installation with Helm is ",[57,152,154],{"href":153},"\u002Fdocs\u002Fsso-keycloak","Single sign-on with Keycloak",".\nWhat the mapped groups can then do is decided by folder permissions: see\n",[57,157,159],{"href":158},"\u002Fdocs\u002Fgovernance\u002Fpermissions","Permissions"," and the ",[57,162,164],{"href":163},"\u002Fdocs\u002Fgovernance","Security and governance"," overview.",[167,168],"hr",{},[170,171,173],"h2",{"id":172},"goal","Goal",[14,175,176,177,180],{},"Let a self-hosting consumer authenticate users against their own IdP and have the\nIdP's groups (or app roles) drive Tabularia's RBAC — ",[17,178,179],{},"without touching"," the\nauthorization layer, the audit trail, or saved flows.",[170,182,184],{"id":183},"current-auth-model-recap","Current auth model (recap)",[186,187,188,226,250,276],"ul",{},[189,190,191,194,195,198,199,202,203,73,206,209,210,217,218,225],"li",{},[17,192,193],{},"Local login is still the baseline."," ",[22,196,197],{},"POST \u002Fauth\u002Flogin"," verifies an email + bcrypt\npassword and issues an internal ",[17,200,201],{},"JWT"," (",[22,204,205],{},"HS256",[22,207,208],{},"sub = user.id","), see\n",[57,211,214],{"href":212,"rel":213},"https:\u002F\u002Fgithub.com\u002FJumpAsAService\u002FTabularia\u002Fblob\u002Fmain\u002Fgateway\u002Fapp\u002Froutes\u002Fauth.py",[61],[22,215,216],{},"gateway\u002Fapp\u002Froutes\u002Fauth.py"," and\n",[57,219,222],{"href":220,"rel":221},"https:\u002F\u002Fgithub.com\u002FJumpAsAService\u002FTabularia\u002Fblob\u002Fmain\u002Fgateway\u002Fapp\u002Fcore\u002Fsecurity.py",[61],[22,223,224],{},"gateway\u002Fapp\u002Fcore\u002Fsecurity.py",".",[189,227,228,194,231,234,235,238,239,242,243,225],{},[17,229,230],{},"Groups are name-keyed.",[22,232,233],{},"groups.name"," is ",[22,236,237],{},"unique","; membership is the\n",[22,240,241],{},"user_groups"," M2M link — ",[57,244,247],{"href":245,"rel":246},"https:\u002F\u002Fgithub.com\u002FJumpAsAService\u002FTabularia\u002Fblob\u002Fmain\u002Fgateway\u002Fapp\u002Fmodels\u002Fuser.py",[61],[22,248,249],{},"gateway\u002Fapp\u002Fmodels\u002Fuser.py",[189,251,252,194,255,258,259,262,263,270,271,275],{},[17,253,254],{},"RBAC reads only local tables.",[22,256,257],{},"user_group_ids()"," → ",[22,260,261],{},"readable_project_ids()"," in\n",[57,264,267],{"href":265,"rel":266},"https:\u002F\u002Fgithub.com\u002FJumpAsAService\u002FTabularia\u002Fblob\u002Fmain\u002Fgateway\u002Fapp\u002Fservices\u002Fpermissions.py",[61],[22,268,269],{},"gateway\u002Fapp\u002Fservices\u002Fpermissions.py","\nresolves project permissions from group membership. It is fully decoupled from ",[272,273,274],"em",{},"how","\nidentity was established.",[189,277,278,281,282,285,286,225],{},[17,279,280],{},"Auth has exactly two seams."," The login route (token issuance) and\n",[22,283,284],{},"get_current_user"," (token acceptance) in\n",[57,287,290],{"href":288,"rel":289},"https:\u002F\u002Fgithub.com\u002FJumpAsAService\u002FTabularia\u002Fblob\u002Fmain\u002Fgateway\u002Fapp\u002Fdeps\u002Fauth.py",[61],[22,291,292],{},"gateway\u002Fapp\u002Fdeps\u002Fauth.py",[14,294,295,296,298,299,305],{},"Because RBAC only reads ",[22,297,241],{},", the mapping problem reduces to ",[17,300,301,302,304],{},"keeping\n",[22,303,241],{}," in sync with the IdP claim at login",". Everything downstream is unchanged.",[170,307,309],{"id":308},"design-principles","Design principles",[311,312,313,334,348,366],"ol",{},[189,314,315,318,319,322,323,326,327,330,331,333],{},[17,316,317],{},"Keep the internal session."," After validating the IdP token, open the ",[272,320,321],{},"same","\nTabularia session a local sign-in opens (",[22,324,325],{},"sessions.apri",", method ",[22,328,329],{},"sso",").\n",[22,332,284],{},", RBAC, and audit stay byte-for-byte the same. This is the\nsmallest-blast-radius choice.",[189,335,336,339,340,343,344,347],{},[17,337,338],{},"Groups map by name."," An IdP claim ",[22,341,342],{},"groups: [\"analytics\", \"finance\"]"," maps 1:1\nonto ",[22,345,346],{},"Group.name",". No IDs, no external coupling in the RBAC layer.",[189,349,350,353,354,357,358,361,362,365],{},[17,351,352],{},"JIT provisioning."," Create the ",[22,355,356],{},"User"," on first SSO login from the token's ",[22,359,360],{},"email","\n\u002F ",[22,363,364],{},"name"," claims; never require a pre-created local account.",[189,367,368,371],{},[17,369,370],{},"Local auth stays available."," SSO is additive. Break-glass local admin login keeps\nworking (important if the IdP is down).",[170,373,375],{"id":374},"flow-authorization-code--internal-jwt","Flow (Authorization Code + internal JWT)",[377,378,383],"pre",{"className":379,"code":381,"language":382},[380],"language-text","Browser                Gateway                         IdP (Keycloak\u002FEntra)\n   │  \"Sign in with SSO\"  │                                  │\n   ├─────────────────────>│  GET \u002Fauth\u002Fsso\u002Flogin             │\n   │                      ├── 302 authorize (state, nonce) ─>│\n   │\u003C──────────────── redirect to IdP login ─────────────────┤\n   │  authenticate + consent                                  │\n   ├────────────────────── code (redirect_uri) ─────────────>│\n   │                      │  GET \u002Fauth\u002Fsso\u002Fcallback?code&state│\n   │                      ├── token exchange (code) ────────>│\n   │                      │\u003C── id_token + access_token ───────┤\n   │                      │  validate (JWKS, iss, aud, nonce) │\n   │                      │  JIT-provision user               │\n   │                      │  sync user_groups from claim      │\n   │                      │  open INTERNAL session            │\n   │\u003C─── session cookie (same as local login) ────────────────┤\n   │  ... every later request unchanged (HttpOnly cookie)     │\n","text",[22,384,381],{"__ignoreMap":385},"",[170,387,389],{"id":388},"the-two-seams","The two seams",[391,392,394],"h3",{"id":393},"1-token-acceptance--sso-routes","1. Token acceptance — SSO routes",[14,396,397,400,401,404,405,73,408,411,412,415,416,419,420,423,424,73,427,73,430,433,434,436,437,440,441,443,444,447,448,451],{},[22,398,399],{},"GET \u002Fauth\u002Fsso\u002Flogin"," redirects to the IdP ",[22,402,403],{},"authorize"," endpoint with ",[22,406,407],{},"state",[22,409,410],{},"nonce","\nand a PKCE challenge, all carried in a signed, HttpOnly, 10-minute cookie scoped to\n",[22,413,414],{},"\u002Fauth\u002Fsso"," (no server-side session, so replicas and restarts are fine).\n",[22,417,418],{},"GET \u002Fauth\u002Fsso\u002Fcallback"," exchanges the code and validates the id_token against the IdP\n",[17,421,422],{},"JWKS",", checking ",[22,425,426],{},"iss",[22,428,429],{},"aud",[22,431,432],{},"exp"," and ",[22,435,410],{},". Discovery comes from the IdP's\n",[22,438,439],{},".well-known\u002Fopenid-configuration",", cached for an hour. Local ",[22,442,197],{}," is\nuntouched. ",[22,445,446],{},"GET \u002Fauth\u002Fsso\u002Fconfig"," is public and tells the login page whether to show\nthe button; it exposes only ",[22,449,450],{},"enabled"," and the label, never issuer or client id.",[14,453,454,455,73,458,73,461,464,465,468,469,472],{},"Only asymmetric signatures are accepted (",[22,456,457],{},"RS*",[22,459,460],{},"PS*",[22,462,463],{},"ES*","): never ",[22,466,467],{},"none",", never\n",[22,470,471],{},"HS*"," with the client secret as key.",[14,474,475,476,479,480,483,484,73,487,490,491,494],{},"The internal session reaches the browser as the same ",[22,477,478],{},"HttpOnly"," cookie a local sign-in\ngets, written on the redirect to ",[22,481,482],{},"OIDC__POST_LOGIN_URL",": the token never appears in a\nURL nor in JavaScript, and the landing page only asks who signed in. (Earlier\nversions carried the token in the URL ",[17,485,486],{},"fragment",[22,488,489],{},"#token=…",".) Errors still come back\nin the fragment, as ",[22,492,493],{},"#error=\u003Ccode>",", which holds no secret; the landing page clears\nthe address bar immediately.",[391,496,498],{"id":497},"2-group-sync--one-reconcile-function","2. Group sync — one reconcile function",[14,500,501,502,505],{},"On successful validation, ",[22,503,504],{},"provision_and_sync()"," reconciles membership from the token's\ngroup\u002Frole claim. The shipped version follows this shape:",[377,507,511],{"className":508,"code":509,"language":510,"meta":385,"style":385},"language-python shiki shiki-themes github-dark-dimmed github-light","# gateway\u002Fapp\u002Fservices\u002Fsso.py  (shape; read the file for the real thing)\ndef provision_and_sync(session, claims, cfg) -> User:\n    email = claims[\"email\"]\n    user = session.exec(select(User).where(User.email == email)).first()\n    if user is None:\n        user = User(email=email, full_name=claims.get(\"name\", \"\"),\n                    hashed_password=\"\", is_active=True)   # SSO-only: no local pw\n        session.add(user); session.flush()\n\n    idp_groups = set(claims.get(cfg.groups_claim, []))    # e.g. {\"analytics\",\"finance\"}\n    if cfg.group_allowlist:                               # optional: restrict\u002Frename\n        idp_groups &= cfg.group_allowlist\n    if cfg.superuser_group:\n        user.is_superuser = cfg.superuser_group in claims.get(cfg.groups_claim, [])\n\n    # ensure Group rows exist (auto-create is a policy toggle)\n    wanted_ids = _resolve_group_ids(session, idp_groups, auto_create=cfg.auto_create)\n\n    current = set(user_group_ids(session, user))\n    for gid in wanted_ids - current:\n        session.add(UserGroupLink(user_id=user.id, group_id=gid))\n    if cfg.authoritative:                                 # IdP owns membership\n        for gid in current - wanted_ids:\n            session.delete(session.get(UserGroupLink, (user.id, gid)))\n    session.commit()\n    return user\n","python",[22,512,513,522,537,556,573,592,630,656,662,669,686,697,709,717,734,739,745,764,769,782,802,824,835,853,859,865],{"__ignoreMap":385},[514,515,518],"span",{"class":516,"line":517},"line",1,[514,519,521],{"class":520},"sgHix","# gateway\u002Fapp\u002Fservices\u002Fsso.py  (shape; read the file for the real thing)\n",[514,523,525,529,533],{"class":516,"line":524},2,[514,526,528],{"class":527},"s6PUj","def",[514,530,532],{"class":531},"saVmf"," provision_and_sync",[514,534,536],{"class":535},"ssh_m","(session, claims, cfg) -> User:\n",[514,538,540,543,546,549,553],{"class":516,"line":539},3,[514,541,542],{"class":535},"    email ",[514,544,545],{"class":527},"=",[514,547,548],{"class":535}," claims[",[514,550,552],{"class":551},"sXfbr","\"email\"",[514,554,555],{"class":535},"]\n",[514,557,559,562,564,567,570],{"class":516,"line":558},4,[514,560,561],{"class":535},"    user ",[514,563,545],{"class":527},[514,565,566],{"class":535}," session.exec(select(User).where(User.email ",[514,568,569],{"class":527},"==",[514,571,572],{"class":535}," email)).first()\n",[514,574,576,579,582,585,589],{"class":516,"line":575},5,[514,577,578],{"class":527},"    if",[514,580,581],{"class":535}," user ",[514,583,584],{"class":527},"is",[514,586,588],{"class":587},"swcJU"," None",[514,590,591],{"class":535},":\n",[514,593,595,598,600,603,606,608,611,614,616,619,622,624,627],{"class":516,"line":594},6,[514,596,597],{"class":535},"        user ",[514,599,545],{"class":527},[514,601,602],{"class":535}," User(",[514,604,360],{"class":605},"sNjOc",[514,607,545],{"class":527},[514,609,610],{"class":535},"email, ",[514,612,613],{"class":605},"full_name",[514,615,545],{"class":527},[514,617,618],{"class":535},"claims.get(",[514,620,621],{"class":551},"\"name\"",[514,623,73],{"class":535},[514,625,626],{"class":551},"\"\"",[514,628,629],{"class":535},"),\n",[514,631,633,636,638,640,642,645,647,650,653],{"class":516,"line":632},7,[514,634,635],{"class":605},"                    hashed_password",[514,637,545],{"class":527},[514,639,626],{"class":551},[514,641,73],{"class":535},[514,643,644],{"class":605},"is_active",[514,646,545],{"class":527},[514,648,649],{"class":587},"True",[514,651,652],{"class":535},")   ",[514,654,655],{"class":520},"# SSO-only: no local pw\n",[514,657,659],{"class":516,"line":658},8,[514,660,661],{"class":535},"        session.add(user); session.flush()\n",[514,663,665],{"class":516,"line":664},9,[514,666,668],{"emptyLinePlaceholder":667},true,"\n",[514,670,672,675,677,680,683],{"class":516,"line":671},10,[514,673,674],{"class":535},"    idp_groups ",[514,676,545],{"class":527},[514,678,679],{"class":587}," set",[514,681,682],{"class":535},"(claims.get(cfg.groups_claim, []))    ",[514,684,685],{"class":520},"# e.g. {\"analytics\",\"finance\"}\n",[514,687,689,691,694],{"class":516,"line":688},11,[514,690,578],{"class":527},[514,692,693],{"class":535}," cfg.group_allowlist:                               ",[514,695,696],{"class":520},"# optional: restrict\u002Frename\n",[514,698,700,703,706],{"class":516,"line":699},12,[514,701,702],{"class":535},"        idp_groups ",[514,704,705],{"class":527},"&=",[514,707,708],{"class":535}," cfg.group_allowlist\n",[514,710,712,714],{"class":516,"line":711},13,[514,713,578],{"class":527},[514,715,716],{"class":535}," cfg.superuser_group:\n",[514,718,720,723,725,728,731],{"class":516,"line":719},14,[514,721,722],{"class":535},"        user.is_superuser ",[514,724,545],{"class":527},[514,726,727],{"class":535}," cfg.superuser_group ",[514,729,730],{"class":527},"in",[514,732,733],{"class":535}," claims.get(cfg.groups_claim, [])\n",[514,735,737],{"class":516,"line":736},15,[514,738,668],{"emptyLinePlaceholder":667},[514,740,742],{"class":516,"line":741},16,[514,743,744],{"class":520},"    # ensure Group rows exist (auto-create is a policy toggle)\n",[514,746,748,751,753,756,759,761],{"class":516,"line":747},17,[514,749,750],{"class":535},"    wanted_ids ",[514,752,545],{"class":527},[514,754,755],{"class":535}," _resolve_group_ids(session, idp_groups, ",[514,757,758],{"class":605},"auto_create",[514,760,545],{"class":527},[514,762,763],{"class":535},"cfg.auto_create)\n",[514,765,767],{"class":516,"line":766},18,[514,768,668],{"emptyLinePlaceholder":667},[514,770,772,775,777,779],{"class":516,"line":771},19,[514,773,774],{"class":535},"    current ",[514,776,545],{"class":527},[514,778,679],{"class":587},[514,780,781],{"class":535},"(user_group_ids(session, user))\n",[514,783,785,788,791,793,796,799],{"class":516,"line":784},20,[514,786,787],{"class":527},"    for",[514,789,790],{"class":535}," gid ",[514,792,730],{"class":527},[514,794,795],{"class":535}," wanted_ids ",[514,797,798],{"class":527},"-",[514,800,801],{"class":535}," current:\n",[514,803,805,808,811,813,816,819,821],{"class":516,"line":804},21,[514,806,807],{"class":535},"        session.add(UserGroupLink(",[514,809,810],{"class":605},"user_id",[514,812,545],{"class":527},[514,814,815],{"class":535},"user.id, ",[514,817,818],{"class":605},"group_id",[514,820,545],{"class":527},[514,822,823],{"class":535},"gid))\n",[514,825,827,829,832],{"class":516,"line":826},22,[514,828,578],{"class":527},[514,830,831],{"class":535}," cfg.authoritative:                                 ",[514,833,834],{"class":520},"# IdP owns membership\n",[514,836,838,841,843,845,848,850],{"class":516,"line":837},23,[514,839,840],{"class":527},"        for",[514,842,790],{"class":535},[514,844,730],{"class":527},[514,846,847],{"class":535}," current ",[514,849,798],{"class":527},[514,851,852],{"class":535}," wanted_ids:\n",[514,854,856],{"class":516,"line":855},24,[514,857,858],{"class":535},"            session.delete(session.get(UserGroupLink, (user.id, gid)))\n",[514,860,862],{"class":516,"line":861},25,[514,863,864],{"class":535},"    session.commit()\n",[514,866,868,871],{"class":516,"line":867},26,[514,869,870],{"class":527},"    return",[514,872,873],{"class":535}," user\n",[14,875,876,877,879],{},"RBAC then works unchanged because ",[22,878,257],{}," reads the very rows this function\nmaintains.",[170,881,883],{"id":882},"mapping-semantics-policy-toggles-config-driven","Mapping semantics (policy toggles, config-driven)",[30,885,886,899],{},[33,887,888],{},[36,889,890,893,896],{},[39,891,892],{},"Concern",[39,894,895],{},"Options",[39,897,898],{},"Default",[46,900,901,914,931,947,968],{},[36,902,903,908,911],{},[51,904,905],{},[17,906,907],{},"Authoritative vs additive",[51,909,910],{},"IdP removes memberships not in the claim \u002F SSO only adds",[51,912,913],{},"authoritative",[36,915,916,921,928],{},[51,917,918],{},[17,919,920],{},"Auto-create groups",[51,922,923,924,927],{},"create missing ",[22,925,926],{},"Group"," rows \u002F only map to pre-existing (allowlist)",[51,929,930],{},"allowlist",[36,932,933,938,944],{},[51,934,935],{},[17,936,937],{},"Superuser",[51,939,940,941],{},"membership of a designated IdP group\u002Frole grants ",[22,942,943],{},"is_superuser",[51,945,946],{},"off",[36,948,949,954,964],{},[51,950,951],{},[17,952,953],{},"Claim source",[51,955,956,959,960,963],{},[22,957,958],{},"groups"," (Keycloak group membership) or ",[22,961,962],{},"roles"," \u002F app roles (Entra)",[51,965,966],{},[22,967,958],{},[36,969,970,975,982],{},[51,971,972],{},[17,973,974],{},"Name normalization",[51,976,977,978,981],{},"strip leading ",[22,979,980],{},"\u002F"," (Keycloak paths), lowercase, optional prefix filter",[51,983,984,985],{},"strip ",[22,986,980],{},[170,988,990],{"id":989},"data-model-changes","Data-model changes",[186,992,993,1006],{},[189,994,995,234,998,1001,1002,225],{},[22,996,997],{},"users.hashed_password",[17,999,1000],{},"nullable",": an SSO-only user has no local password, and\nlocal login refuses those accounts outright (no empty-password back door). Forward\nmigration: today an Alembic revision, see\n",[57,1003,1005],{"href":1004},"\u002Fdocs\u002Fdatabase-migrations","database-migrations.md",[189,1007,1008,1011,1012,433,1015,1018,1019,1022],{},[272,1009,1010],{},"(Optional)"," add ",[22,1013,1014],{},"users.idp",[22,1016,1017],{},"users.external_id"," to disambiguate identities\nacross providers and pin an account to an IdP subject (",[22,1020,1021],{},"sub","). Not required if email\nis the stable key.",[14,1024,1025,1026,73,1028,1030],{},"No changes to ",[22,1027,958],{},[22,1029,241],{},", or any RBAC\u002Fpermission code.",[170,1032,1034,1035,1037],{"id":1033},"configuration-proposed-oidc__-block","Configuration (proposed ",[22,1036,95],{}," block)",[14,1039,1040,1041,1044,1045,1050],{},"Follows the existing nested-settings convention (",[22,1042,1043],{},"env_nested_delimiter=\"__\"",", see\n",[57,1046,1048],{"href":101,"rel":1047},[61],[22,1049,105],{},"):",[377,1052,1056],{"className":1053,"code":1054,"language":1055,"meta":385,"style":385},"language-dotenv shiki shiki-themes github-dark-dimmed github-light","OIDC__ENABLED=true\nOIDC__ISSUER=https:\u002F\u002Fkeycloak.example.com\u002Frealms\u002Ftabularia\nOIDC__CLIENT_ID=tabularia-gateway\nOIDC__CLIENT_SECRET=...                     # confidential client\nOIDC__REDIRECT_URI=https:\u002F\u002Fapp.example.com\u002Fauth\u002Fsso\u002Fcallback\nOIDC__SCOPES=openid profile email groups\nOIDC__GROUPS_CLAIM=groups                   # 'roles' for Entra app roles\nOIDC__GROUP_ALLOWLIST=                      # empty = allow all\nOIDC__AUTO_CREATE_GROUPS=false\nOIDC__AUTHORITATIVE=true\nOIDC__SUPERUSER_GROUP=tabularia-admins\nOIDC__POST_LOGIN_URL=http:\u002F\u002Flocalhost:3000\u002Fauth\u002Fcallback   # frontend landing page\nOIDC__BUTTON_LABEL=Single sign-on                          # label on the login page\n","dotenv",[22,1057,1058,1068,1077,1087,1100,1110,1120,1133,1143,1153,1162,1172,1184],{"__ignoreMap":385},[514,1059,1060,1063,1065],{"class":516,"line":517},[514,1061,1062],{"class":605},"OIDC__ENABLED",[514,1064,545],{"class":527},[514,1066,1067],{"class":535},"true\n",[514,1069,1070,1072,1074],{"class":516,"line":524},[514,1071,24],{"class":605},[514,1073,545],{"class":527},[514,1075,1076],{"class":535},"https:\u002F\u002Fkeycloak.example.com\u002Frealms\u002Ftabularia\n",[514,1078,1079,1082,1084],{"class":516,"line":539},[514,1080,1081],{"class":605},"OIDC__CLIENT_ID",[514,1083,545],{"class":527},[514,1085,1086],{"class":535},"tabularia-gateway\n",[514,1088,1089,1092,1094,1097],{"class":516,"line":558},[514,1090,1091],{"class":605},"OIDC__CLIENT_SECRET",[514,1093,545],{"class":527},[514,1095,1096],{"class":535},"...                     ",[514,1098,1099],{"class":520},"# confidential client\n",[514,1101,1102,1105,1107],{"class":516,"line":575},[514,1103,1104],{"class":605},"OIDC__REDIRECT_URI",[514,1106,545],{"class":527},[514,1108,1109],{"class":535},"https:\u002F\u002Fapp.example.com\u002Fauth\u002Fsso\u002Fcallback\n",[514,1111,1112,1115,1117],{"class":516,"line":594},[514,1113,1114],{"class":605},"OIDC__SCOPES",[514,1116,545],{"class":527},[514,1118,1119],{"class":535},"openid profile email groups\n",[514,1121,1122,1125,1127,1130],{"class":516,"line":632},[514,1123,1124],{"class":605},"OIDC__GROUPS_CLAIM",[514,1126,545],{"class":527},[514,1128,1129],{"class":535},"groups                   ",[514,1131,1132],{"class":520},"# 'roles' for Entra app roles\n",[514,1134,1135,1138,1140],{"class":516,"line":658},[514,1136,1137],{"class":605},"OIDC__GROUP_ALLOWLIST",[514,1139,545],{"class":527},[514,1141,1142],{"class":520},"                      # empty = allow all\n",[514,1144,1145,1148,1150],{"class":516,"line":664},[514,1146,1147],{"class":605},"OIDC__AUTO_CREATE_GROUPS",[514,1149,545],{"class":527},[514,1151,1152],{"class":535},"false\n",[514,1154,1155,1158,1160],{"class":516,"line":671},[514,1156,1157],{"class":605},"OIDC__AUTHORITATIVE",[514,1159,545],{"class":527},[514,1161,1067],{"class":535},[514,1163,1164,1167,1169],{"class":516,"line":688},[514,1165,1166],{"class":605},"OIDC__SUPERUSER_GROUP",[514,1168,545],{"class":527},[514,1170,1171],{"class":535},"tabularia-admins\n",[514,1173,1174,1176,1178,1181],{"class":516,"line":699},[514,1175,482],{"class":605},[514,1177,545],{"class":527},[514,1179,1180],{"class":535},"http:\u002F\u002Flocalhost:3000\u002Fauth\u002Fcallback   ",[514,1182,1183],{"class":520},"# frontend landing page\n",[514,1185,1186,1189,1191,1194],{"class":516,"line":711},[514,1187,1188],{"class":605},"OIDC__BUTTON_LABEL",[514,1190,545],{"class":527},[514,1192,1193],{"class":535},"Single sign-on                          ",[514,1195,1196],{"class":520},"# label on the login page\n",[14,1198,1199,1200,1203],{},"The gateway self-configures from ",[22,1201,1202],{},"${OIDC__ISSUER}\u002F.well-known\u002Fopenid-configuration","\n(authorize\u002Ftoken\u002FJWKS endpoints), so only the issuer + client credentials are needed.",[170,1205,1207],{"id":1206},"security-considerations","Security considerations",[186,1209,1210,1230,1239,1253,1263],{},[189,1211,1212,1215,1216,73,1218,1220,1221,1223,1224,1226,1227,225],{},[17,1213,1214],{},"Validate the token, don't trust it."," Verify signature against the IdP JWKS\n(cache + rotate), and check ",[22,1217,426],{},[22,1219,429],{}," (= client_id), ",[22,1222,432],{},", and the ",[22,1225,410],{}," bound\nto the login request. Reject unsigned\u002F",[22,1228,1229],{},"alg:none",[189,1231,1232,1235,1236,1238],{},[17,1233,1234],{},"CSRF on the callback."," Bind and verify ",[22,1237,407],{},"; use PKCE for the code flow.",[189,1240,1241,1244,1245,1248,1249,1252],{},[17,1242,1243],{},"Session lifetime."," The Tabularia session lasts at most ",[22,1246,1247],{},"JWT__ACCESS_TTL_MINUTES","\n(12h), less with an idle timeout (",[22,1250,1251],{},"AUTH__SESSION_IDLE_MINUTES","); group changes at the\nIdP take effect on the next SSO login.\nFor faster deprovisioning, shorten the TTL or re-sync on a schedule.",[189,1254,1255,1258,1259,1262],{},[17,1256,1257],{},"Deprovisioning."," With ",[22,1260,1261],{},"AUTHORITATIVE=true",", dropping a user from an IdP group\nremoves the corresponding Tabularia access at next login; disabling the user at the\nIdP stops new logins but does not revoke a live JWT until it expires.",[189,1264,1265,1268,1269,1272,1273,1276,1277,1280,1281,1284],{},[17,1266,1267],{},"Audit."," Emit ",[22,1270,1271],{},"LOGIN"," (and a new ",[22,1274,1275],{},"SSO_LOGIN"," \u002F ",[22,1278,1279],{},"GROUP_SYNC",") audit events through\nthe existing ",[22,1282,1283],{},"record_audit",", including the resolved group delta.",[170,1286,1288],{"id":1287},"what-shipped","What shipped",[186,1290,1291,1298,1308,1315,1322,1329,1339],{},[189,1292,1293,1294,1297],{},"one service (",[22,1295,1296],{},"sso.py",": discovery, PKCE, token validation, provision + sync),",[189,1299,1300,1301,73,1303,73,1305,1307],{},"three routes (",[22,1302,72],{},[22,1304,76],{},[22,1306,79],{},"),",[189,1309,1310,1311,1314],{},"an ",[22,1312,1313],{},"OidcSettings"," block plus a startup check that refuses a half-configured IdP,",[189,1316,1317,1318,1321],{},"a nullable-",[22,1319,1320],{},"hashed_password"," migration,",[189,1323,1324,1325,1328],{},"a \"Sign in with SSO\" button and a ",[22,1326,1327],{},"\u002Fauth\u002Fcallback"," landing page in the frontend,",[189,1330,1331,1332,73,1335,1338],{},"two audit actions (",[22,1333,1334],{},"auth.sso_login",[22,1336,1337],{},"auth.sso_login_failed",") carrying the group delta,",[189,1340,1341,1344,1345,1348,1349,202,1352,1355],{},[17,1342,1343],{},"no new dependency",": discovery and token exchange use ",[22,1346,1347],{},"httpx",", validation uses\n",[22,1350,1351],{},"PyJWT",[22,1353,1354],{},"PyJWKClient","), PKCE uses the standard library. All were already present.",[14,1357,1358],{},"As predicted, the group mapping is the trivial half (reconciling name-keyed rows); the\nOIDC front door was the work.",[170,1360,1362],{"id":1361},"deliberately-left-out","Deliberately left out",[186,1364,1365,1374,1383,1389,1395,1415],{},[189,1366,1367,1370,1371,1373],{},[17,1368,1369],{},"One IdP per deployment."," Multiple simultaneous providers would need ",[22,1372,1014],{}," to\ndisambiguate identities; email is the stable key today.",[189,1375,1376,1379,1380,1382],{},[17,1377,1378],{},"No SCIM \u002F background sync."," Membership is reconciled at login. A user removed from\nan IdP group keeps their Tabularia access until their session ends\n(",[22,1381,1247],{},", 12h by default, or the idle timeout) and they sign in\nagain — or at once, if an administrator closes their sessions from the Audit page.",[189,1384,1385,1388],{},[17,1386,1387],{},"No single logout."," Signing out of Tabularia drops the local session; the IdP\nsession is untouched.",[189,1390,1391,1394],{},[17,1392,1393],{},"Group auto-creation is off by default."," Mapping onto admin-curated groups keeps\nRBAC hygiene; turn it on only when the IdP is the source of truth for group names.",[189,1396,1397,1400,1401,1404,1405,1407,1408,1411,1412,1414],{},[17,1398,1399],{},"Groups that carry a role are never mapped by name."," A Tabularia group marked\n",[272,1402,1403],{},"administrators"," is neither granted nor removed by an SSO sign-in, whatever the IdP's\ngroup names: administration comes from the IdP only through ",[22,1406,1166],{},".\nAn ",[272,1409,1410],{},"observers"," group is mapped only when it is listed in ",[22,1413,1137],{},".\nOtherwise anyone able to put a group with the right name on their token (self-service\ngroups on Entra ID, a loosely managed Keycloak realm) would become administrator.",[189,1416,1417,1420,1421,1424,1425,1428],{},[17,1418,1419],{},"API tokens outlive the IdP."," A personal API token keeps working until it expires\n(",[22,1422,1423],{},"AUTH__API_TOKEN_MAX_DAYS",") or the account is deactivated in Tabularia, even after\nthe user is disabled at the IdP: when someone leaves, deactivate them in Tabularia\ntoo (it closes their sessions and revokes their tokens), or set\n",[22,1426,1427],{},"AUTH__PERSONAL_TOKENS=false"," where only service accounts should hold tokens.",[1430,1431,1432],"style",{},"html pre.shiki code .sgHix, html code.shiki .sgHix{--shiki-default:#768390;--shiki-light:#6A737D}html pre.shiki code .s6PUj, html code.shiki .s6PUj{--shiki-default:#F47067;--shiki-light:#D73A49}html pre.shiki code .saVmf, html code.shiki .saVmf{--shiki-default:#DCBDFB;--shiki-light:#6F42C1}html pre.shiki code .ssh_m, html code.shiki .ssh_m{--shiki-default:#ADBAC7;--shiki-light:#24292E}html pre.shiki code .sXfbr, html code.shiki .sXfbr{--shiki-default:#96D0FF;--shiki-light:#032F62}html pre.shiki code .swcJU, html code.shiki .swcJU{--shiki-default:#6CB6FF;--shiki-light:#005CC5}html pre.shiki code .sNjOc, html code.shiki .sNjOc{--shiki-default:#F69D50;--shiki-light:#E36209}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}",{"title":385,"searchDepth":524,"depth":539,"links":1434},[1435,1436,1437,1438,1439,1443,1444,1445,1447,1448,1449],{"id":172,"depth":524,"text":173},{"id":183,"depth":524,"text":184},{"id":308,"depth":524,"text":309},{"id":374,"depth":524,"text":375},{"id":388,"depth":524,"text":389,"children":1440},[1441,1442],{"id":393,"depth":539,"text":394},{"id":497,"depth":539,"text":498},{"id":882,"depth":524,"text":883},{"id":989,"depth":524,"text":990},{"id":1033,"depth":524,"text":1446},"Configuration (proposed OIDC__* block)",{"id":1206,"depth":524,"text":1207},{"id":1287,"depth":524,"text":1288},{"id":1361,"depth":524,"text":1362},"Status: implemented and optional. An external identity provider (Keycloak,\nMicrosoft Entra ID \u002F MSAL, Auth0, Okta, …) plugs into the gateway and its groups or\napp roles drive Tabularia's RBAC. The feature is inert until OIDC__ISSUER is set.","md",{"source":1453},"docs\u002Fdesign\u002Fsso-group-mapping.md","\u002Fdocs\u002Fdesign\u002Fsso-group-mapping",{"title":5,"description":1450},"docs\u002Fdesign\u002Fsso-group-mapping","iQk93uECQ9-i3EpFDSCIxPyircKXZS5_12acWMWLyQM",[1459,1478,1534,1566,1598,1625,1646,1671],{"title":1460,"icon":1461,"intro":1462,"items":1463},"Getting started","start","Install it on one machine, learn the few ideas everything else is built on, and run a first flow.",[1464,1466,1469,1472,1475],{"path":1465,"title":1460},"\u002Fdocs\u002Fgetting-started",{"path":1467,"title":1468},"\u002Fdocs\u002Fgetting-started-docker-compose","Walkthrough with Docker Compose",{"path":1470,"title":1471},"\u002Fdocs\u002Fdeploy\u002Fminikube","Try it on your computer (minikube)",{"path":1473,"title":1474},"\u002Fdocs\u002Fconcepts","Concepts",{"path":1476,"title":1477},"\u002Fdocs\u002Foverview","Overview (README)",{"title":1479,"icon":1480,"intro":1481,"items":1482},"Capabilities","capabilities","What you can build: visual flows, analysis on any step, AI questions, pixel-perfect reports, dashboards, Python, R and SQL scripts, containers and the orchestration that ties them together.",[1483,1486,1489,1492,1495,1498,1501,1504,1507,1510,1513,1516,1519,1522,1525,1528,1531],{"path":1484,"title":1485},"\u002Fdocs\u002Fcapabilities","All capabilities",{"path":1487,"title":1488},"\u002Fdocs\u002Fcapabilities\u002Fflows","Low-code flows",{"path":1490,"title":1491},"\u002Fdocs\u002Fcapabilities\u002Fpreview-and-analysis","Preview and analysis",{"path":1493,"title":1494},"\u002Fdocs\u002Fcapabilities\u002Fask-your-data","Ask your data with AI",{"path":1496,"title":1497},"\u002Fdocs\u002Fcapabilities\u002Freports","Pixel-perfect reports",{"path":1499,"title":1500},"\u002Fdocs\u002Fcapabilities\u002Freport-functions","Report functions",{"path":1502,"title":1503},"\u002Fdocs\u002Fcapabilities\u002Fdashboards","Dashboards",{"path":1505,"title":1506},"\u002Fdocs\u002Fscripting","Python and R",{"path":1508,"title":1509},"\u002Fdocs\u002Fsql-scripts","SQL scripts",{"path":1511,"title":1512},"\u002Fdocs\u002Fcapabilities\u002Fnotebooks","marimo notebooks as apps",{"path":1514,"title":1515},"\u002Fdocs\u002Fcontainer-node","Docker images in flows",{"path":1517,"title":1518},"\u002Fdocs\u002Fcapabilities\u002Fflow-groups","Flow groups: orchestration",{"path":1520,"title":1521},"\u002Fdocs\u002Fcapabilities\u002Fscheduling","Scheduling",{"path":1523,"title":1524},"\u002Fdocs\u002Fpushmail","Pushmail: datasources, reports and dashboards by email",{"path":1526,"title":1527},"\u002Fdocs\u002Fplaceholders","Dynamic dates",{"path":1529,"title":1530},"\u002Fdocs\u002Fcapabilities\u002Fengines","Engines",{"path":1532,"title":1533},"\u002Fdocs\u002Fengines\u002Fengine-differences","Engine differences",{"title":1535,"icon":1536,"intro":1537,"items":1538},"Sources","sources","Every way data comes in: files, databases, buckets, SFTP folders, HTTP APIs, SharePoint, Google Sheets, and the results of your own flows.",[1539,1542,1545,1548,1551,1554,1557,1560,1563],{"path":1540,"title":1541},"\u002Fdocs\u002Fsources","All sources",{"path":1543,"title":1544},"\u002Fdocs\u002Fsources\u002Ffiles","Uploaded files",{"path":1546,"title":1547},"\u002Fdocs\u002Fsources\u002Fdatabases","Database tables and queries",{"path":1549,"title":1550},"\u002Fdocs\u002Fs3-datasource","S3 buckets",{"path":1552,"title":1553},"\u002Fdocs\u002Fsftp","SFTP folders",{"path":1555,"title":1556},"\u002Fdocs\u002Fapi-datasource","HTTP APIs",{"path":1558,"title":1559},"\u002Fdocs\u002Fsources\u002Fsharepoint","Excel on SharePoint",{"path":1561,"title":1562},"\u002Fdocs\u002Fsources\u002Fgoogle-sheets","Google Sheets",{"path":1564,"title":1565},"\u002Fdocs\u002Fsources\u002Fflow-outputs","Results of flows and scripts",{"title":1567,"icon":1568,"intro":1569,"items":1570},"Connections","connections","The systems Tabularia reads from and writes to, how each connection is set up, and who may use it.",[1571,1574,1577,1580,1583,1585,1586,1588,1589,1592,1595],{"path":1572,"title":1573},"\u002Fdocs\u002Fconnections","All connections",{"path":1575,"title":1576},"\u002Fdocs\u002Fconnections\u002Fdatabases","Databases",{"path":1578,"title":1579},"\u002Fdocs\u002Fsqlserver","Microsoft SQL Server driver",{"path":1581,"title":1582},"\u002Fdocs\u002Fconnections\u002Fs3","S3 and compatible storage",{"path":1552,"title":1584},"SFTP servers",{"path":1555,"title":1556},{"path":1558,"title":1587},"SharePoint",{"path":1561,"title":1562},{"path":1590,"title":1591},"\u002Fdocs\u002Fconnections\u002Fsmtp","SMTP email",{"path":1593,"title":1594},"\u002Fdocs\u002Fcontainer-registries","Container registries",{"path":1596,"title":1597},"\u002Fdocs\u002Fconnections\u002Fdestinations","Writing data out",{"title":1599,"icon":1600,"intro":1601,"items":1602},"Integrations","integrations","How Tabularia fits your stack: AI providers, the REST API and webhooks, dbt, OpenLineage, single sign-on, SIEM and monitoring.",[1603,1606,1609,1612,1615,1618,1619,1622],{"path":1604,"title":1605},"\u002Fdocs\u002Fintegrations","All integrations",{"path":1607,"title":1608},"\u002Fdocs\u002Fintegrations\u002Fai","AI: where it is used",{"path":1610,"title":1611},"\u002Fdocs\u002Fapi","API, tokens and webhooks",{"path":1613,"title":1614},"\u002Fdocs\u002Fdbt-export","Export to dbt",{"path":1616,"title":1617},"\u002Fdocs\u002Fopenlineage","OpenLineage",{"path":153,"title":154},{"path":1620,"title":1621},"\u002Fdocs\u002Fsiem-splunk","Audit log to Splunk",{"path":1623,"title":1624},"\u002Fdocs\u002Fintegrations\u002Fmonitoring","Monitoring",{"title":1626,"icon":1627,"intro":1628,"items":1629},"Security & governance","governance","Who may see and do what, what is recorded, and how data quality and personal data are kept in check.",[1630,1632,1633,1636,1639,1641,1643],{"path":163,"title":1631},"Overview",{"path":158,"title":159},{"path":1634,"title":1635},"\u002Fdocs\u002Fgovernance\u002Faudit-log","Audit log",{"path":1637,"title":1638},"\u002Fdocs\u002Fgovernance\u002Fdata-contracts","Data contracts",{"path":1454,"title":1640},"SSO and group mapping",{"path":146,"title":1642},"Keycloak example on a laptop",{"path":1644,"title":1645},"\u002Fdocs\u002Fengines\u002Fclickhouse-ai-user","A ClickHouse user for the AI assistant",{"title":1647,"icon":1648,"intro":1649,"items":1650},"Install & operate","operate","Run it in production on Kubernetes, configure it, upgrade it and keep it healthy.",[1651,1654,1657,1660,1663,1666,1669],{"path":1652,"title":1653},"\u002Fdocs\u002Fdeploy\u002Fkubernetes","Kubernetes and Helm",{"path":1655,"title":1656},"\u002Fdocs\u002Fdeploy\u002Fkind","A company-like cluster on one computer (kind)",{"path":1658,"title":1659},"\u002Fdocs\u002Fdeploy\u002Fupgrading","Upgrading, backups and restore",{"path":1661,"title":1662},"\u002Fdocs\u002Fdeploy\u002Frelease-checklist","Install and upgrade checklist",{"path":1664,"title":1665},"\u002Fdocs\u002Fconfiguration","Configuration reference",{"path":1667,"title":1668},"\u002Fdocs\u002Fengine-settings","Settings from the panel",{"path":1004,"title":1670},"Database migrations",{"title":1672,"icon":1673,"intro":1674,"items":1675},"Reference","reference","Release process and the remaining reference pages.",[1676],{"path":1677,"title":1678},"\u002Fdocs\u002Frelease-process","Release process",1793656372199]