|
51 | 51 | get_current_user_info, |
52 | 52 | get_host, |
53 | 53 | get_metastore_name, |
| 54 | + get_sp_token, |
54 | 55 | get_workspace_name, |
55 | 56 | make_api_call, |
56 | 57 | resolve_account_connection, |
|
87 | 88 | </html>''' |
88 | 89 |
|
89 | 90 | # Default connection config |
90 | | -_DEFAULT_CONN = {"mode": "profile", "profile": DATABRICKS_PROFILE} |
| 91 | +_DEFAULT_CONN = {"mode": "sp"} if IS_DATABRICKS_APP else {"mode": "profile", "profile": DATABRICKS_PROFILE} |
91 | 92 |
|
92 | 93 |
|
93 | 94 | # ── Flask route: serve ~/.databrickscfg in the browser ──────────────────────── |
@@ -724,7 +725,13 @@ def _resolve_conn( |
724 | 725 | A ``(host, token)`` tuple. |
725 | 726 | """ |
726 | 727 | if IS_DATABRICKS_APP: |
727 | | - return get_host(), flask_request.headers.get("x-forwarded-access-token") |
| 728 | + mode = (conn_config or {}).get("mode", "sp") |
| 729 | + if mode == "obo": |
| 730 | + # OBO: read the user's token from the proxy header (requires |
| 731 | + # User Authorization enabled + scopes configured in the app) |
| 732 | + return get_host(), flask_request.headers.get("X-Forwarded-Access-Token") |
| 733 | + # Default: Service Principal |
| 734 | + return get_host(), get_sp_token() |
728 | 735 | return resolve_local_connection(conn_config or _DEFAULT_CONN) |
729 | 736 |
|
730 | 737 |
|
@@ -1053,55 +1060,55 @@ def build_sidebar() -> html.Div: |
1053 | 1060 | _THEMES = { |
1054 | 1061 | "midnight": {"label": "Midnight", "dark": True, "icon": "bi-moon-stars-fill", |
1055 | 1062 | "bg-void": "#050810", "bg-dark": "#080c18", "bg-panel": "#0d1225", |
1056 | | - "bg-surface": "#141a2e", "bg-topbar": "rgba(8,12,24,0.95)", |
| 1063 | + "bg-surface": "#141a2e", "bg-topbar": "#080c18", |
1057 | 1064 | "bg-card": "rgba(255,255,255,0.028)", "bg-hover": "rgba(255,255,255,0.055)", |
1058 | 1065 | "bg-active": "rgba(0,212,255,0.09)", "border": "rgba(255,255,255,0.07)", |
1059 | 1066 | "border-hi": "rgba(0,212,255,0.45)", "text-hi": "#f8fafc", |
1060 | 1067 | "text-1": "#e2e8f0", "text-2": "#94a3b8", "text-3": "#4b5563", |
1061 | 1068 | "accent": "#00d4ff", "card-bg": "#1a1d23"}, |
1062 | 1069 | "obsidian": {"label": "Obsidian", "dark": True, "icon": "bi-gem", |
1063 | 1070 | "bg-void": "#0a0a0a", "bg-dark": "#111111", "bg-panel": "#181818", |
1064 | | - "bg-surface": "#222222", "bg-topbar": "rgba(10,10,10,0.95)", |
| 1071 | + "bg-surface": "#222222", "bg-topbar": "#0a0a0a", |
1065 | 1072 | "bg-card": "rgba(255,255,255,0.035)", "bg-hover": "rgba(255,255,255,0.06)", |
1066 | 1073 | "bg-active": "rgba(168,85,247,0.1)", "border": "rgba(255,255,255,0.08)", |
1067 | 1074 | "border-hi": "rgba(168,85,247,0.5)", "text-hi": "#fafafa", |
1068 | 1075 | "text-1": "#e0e0e0", "text-2": "#9e9e9e", "text-3": "#555555", |
1069 | 1076 | "accent": "#a855f7", "card-bg": "#1e1e1e"}, |
1070 | 1077 | "deep-ocean": {"label": "Deep Ocean", "dark": True, "icon": "bi-water", |
1071 | 1078 | "bg-void": "#020617", "bg-dark": "#0f172a", "bg-panel": "#1e293b", |
1072 | | - "bg-surface": "#263448", "bg-topbar": "rgba(2,6,23,0.95)", |
| 1079 | + "bg-surface": "#263448", "bg-topbar": "#020617", |
1073 | 1080 | "bg-card": "rgba(255,255,255,0.03)", "bg-hover": "rgba(255,255,255,0.05)", |
1074 | 1081 | "bg-active": "rgba(56,189,248,0.1)", "border": "rgba(255,255,255,0.06)", |
1075 | 1082 | "border-hi": "rgba(56,189,248,0.5)", "text-hi": "#f8fafc", |
1076 | 1083 | "text-1": "#cbd5e1", "text-2": "#64748b", "text-3": "#475569", |
1077 | 1084 | "accent": "#38bdf8", "card-bg": "#1e293b"}, |
1078 | 1085 | "aurora": {"label": "Aurora", "dark": True, "icon": "bi-stars", |
1079 | 1086 | "bg-void": "#030712", "bg-dark": "#0c1427", "bg-panel": "#162032", |
1080 | | - "bg-surface": "#1e2a3e", "bg-topbar": "rgba(3,7,18,0.95)", |
| 1087 | + "bg-surface": "#1e2a3e", "bg-topbar": "#030712", |
1081 | 1088 | "bg-card": "rgba(255,255,255,0.03)", "bg-hover": "rgba(255,255,255,0.055)", |
1082 | 1089 | "bg-active": "rgba(16,185,129,0.1)", "border": "rgba(255,255,255,0.07)", |
1083 | 1090 | "border-hi": "rgba(16,185,129,0.5)", "text-hi": "#ecfdf5", |
1084 | 1091 | "text-1": "#d1fae5", "text-2": "#6ee7b7", "text-3": "#34d399", |
1085 | 1092 | "accent": "#10b981", "card-bg": "#1a2332"}, |
1086 | 1093 | "snowlight": {"label": "Snowlight", "dark": False, "icon": "bi-sun-fill", |
1087 | 1094 | "bg-void": "#f8fafc", "bg-dark": "#f1f5f9", "bg-panel": "#ffffff", |
1088 | | - "bg-surface": "#eef2f7", "bg-topbar": "rgba(255,255,255,0.92)", |
| 1095 | + "bg-surface": "#eef2f7", "bg-topbar": "#ffffff", |
1089 | 1096 | "bg-card": "rgba(0,0,0,0.03)", "bg-hover": "rgba(0,0,0,0.05)", |
1090 | 1097 | "bg-active": "rgba(99,102,241,0.08)", "border": "rgba(0,0,0,0.1)", |
1091 | 1098 | "border-hi": "rgba(99,102,241,0.5)", "text-hi": "#0f172a", |
1092 | 1099 | "text-1": "#1e293b", "text-2": "#64748b", "text-3": "#94a3b8", |
1093 | 1100 | "accent": "#6366f1", "card-bg": "#ffffff"}, |
1094 | 1101 | "paper": {"label": "Paper", "dark": False, "icon": "bi-file-earmark-text", |
1095 | 1102 | "bg-void": "#fafaf9", "bg-dark": "#f5f5f4", "bg-panel": "#ffffff", |
1096 | | - "bg-surface": "#edeceb", "bg-topbar": "rgba(255,255,255,0.92)", |
| 1103 | + "bg-surface": "#edeceb", "bg-topbar": "#ffffff", |
1097 | 1104 | "bg-card": "rgba(0,0,0,0.025)", "bg-hover": "rgba(0,0,0,0.04)", |
1098 | 1105 | "bg-active": "rgba(234,88,12,0.08)", "border": "rgba(0,0,0,0.08)", |
1099 | 1106 | "border-hi": "rgba(234,88,12,0.45)", "text-hi": "#1c1917", |
1100 | 1107 | "text-1": "#292524", "text-2": "#78716c", "text-3": "#a8a29e", |
1101 | 1108 | "accent": "#ea580c", "card-bg": "#ffffff"}, |
1102 | 1109 | "cloud": {"label": "Cloud", "dark": False, "icon": "bi-cloud-sun-fill", |
1103 | 1110 | "bg-void": "#f0f9ff", "bg-dark": "#e0f2fe", "bg-panel": "#ffffff", |
1104 | | - "bg-surface": "#daeaf8", "bg-topbar": "rgba(255,255,255,0.92)", |
| 1111 | + "bg-surface": "#daeaf8", "bg-topbar": "#ffffff", |
1105 | 1112 | "bg-card": "rgba(0,0,0,0.02)", "bg-hover": "rgba(0,0,0,0.04)", |
1106 | 1113 | "bg-active": "rgba(14,165,233,0.08)", "border": "rgba(0,0,0,0.08)", |
1107 | 1114 | "border-hi": "rgba(14,165,233,0.45)", "text-hi": "#0c4a6e", |
@@ -1496,17 +1503,47 @@ def _custom_section() -> html.Div: |
1496 | 1503 | html.Div("Connection", className="auth-section-title mb-2"), |
1497 | 1504 | dbc.RadioItems( |
1498 | 1505 | id="conn-mode-radio", |
1499 | | - options=[ |
1500 | | - {"label": "CLI Profile", "value": "profile"}, |
1501 | | - {"label": "SSO Login", "value": "sso"}, |
1502 | | - {"label": "URL + Token", "value": "custom"}, |
1503 | | - ], |
1504 | | - value="profile", |
| 1506 | + options=( |
| 1507 | + [ |
| 1508 | + {"label": "On-Behalf-of User", "value": "obo"}, |
| 1509 | + {"label": "Service Principal", "value": "sp"}, |
| 1510 | + ] if IS_DATABRICKS_APP else [ |
| 1511 | + {"label": "CLI Profile", "value": "profile"}, |
| 1512 | + {"label": "SSO Login", "value": "sso"}, |
| 1513 | + {"label": "URL + Token", "value": "custom"}, |
| 1514 | + ] |
| 1515 | + ), |
| 1516 | + value="sp" if IS_DATABRICKS_APP else "profile", |
1505 | 1517 | inline=True, |
1506 | 1518 | className="conn-mode-radio mb-3", |
1507 | 1519 | input_class_name="conn-radio-input", |
1508 | 1520 | label_class_name="conn-radio-label", |
1509 | 1521 | ), |
| 1522 | + html.Div([ |
| 1523 | + html.Div([ |
| 1524 | + html.I(className="bi bi-person-badge me-2 text-info"), |
| 1525 | + "Using the logged-in user's identity via the ", |
| 1526 | + html.Code("x-forwarded-access-token"), |
| 1527 | + " header.", |
| 1528 | + ], className="conn-hint"), |
| 1529 | + ], id="obo-section", style={"display": "none"}), |
| 1530 | + html.Div([ |
| 1531 | + html.Div([ |
| 1532 | + html.I(className="bi bi-robot me-2 text-warning"), |
| 1533 | + "Using the app's Service Principal credentials ", |
| 1534 | + html.Code("(DATABRICKS_CLIENT_ID / SECRET)"), |
| 1535 | + ".", |
| 1536 | + ], className="conn-hint"), |
| 1537 | + html.Div([ |
| 1538 | + html.I(className="bi bi-exclamation-triangle me-1"), |
| 1539 | + "The Service Principal must be a member of the ", |
| 1540 | + html.Strong("admins"), |
| 1541 | + " group to access all workspace objects. ", |
| 1542 | + "Use the deploy script or add it manually via ", |
| 1543 | + html.Code("Settings → Identity and access → Groups → admins"), |
| 1544 | + ".", |
| 1545 | + ], className="conn-hint mt-2 text-warning", style={"fontSize": "11px"}), |
| 1546 | + ], id="sp-section", style={} if IS_DATABRICKS_APP else {"display": "none"}), |
1510 | 1547 | _profile_section(), |
1511 | 1548 | _sso_section(), |
1512 | 1549 | _custom_section(), |
@@ -1978,7 +2015,8 @@ def populate_dropdown(is_open, conn_config): |
1978 | 2015 |
|
1979 | 2016 | # Auth type |
1980 | 2017 | if IS_DATABRICKS_APP: |
1981 | | - auth_type_display = "On-Behalf-Of (OBO)" |
| 2018 | + app_mode = (conn_config or {}).get("mode", "obo") |
| 2019 | + auth_type_display = "Service Principal (SP)" if app_mode == "sp" else "On-Behalf-Of (OBO)" |
1982 | 2020 | elif conn_config.get("mode") == "custom": |
1983 | 2021 | auth_type_display = "Personal Access Token" |
1984 | 2022 | else: |
@@ -2027,21 +2065,26 @@ def populate_dropdown(is_open, conn_config): |
2027 | 2065 | profile_options, |
2028 | 2066 | ) |
2029 | 2067 |
|
2030 | | -# 4. Show/hide profile vs sso vs custom section |
| 2068 | +# 4. Show/hide profile vs sso vs custom vs obo vs sp section |
2031 | 2069 | @app.callback( |
| 2070 | + Output("obo-section", "style"), |
| 2071 | + Output("sp-section", "style"), |
2032 | 2072 | Output("profile-section", "style"), |
2033 | 2073 | Output("sso-section", "style"), |
2034 | 2074 | Output("custom-section", "style"), |
2035 | 2075 | Input("conn-mode-radio", "value"), |
2036 | 2076 | ) |
2037 | 2077 | def toggle_conn_mode(mode): |
2038 | | - """Callback 4: Show/hide the profile, SSO, or custom connection section.""" |
| 2078 | + """Callback 4: Show/hide the active connection section.""" |
2039 | 2079 | hide = {"display": "none"} |
2040 | | - if mode == "profile": |
2041 | | - return {}, hide, hide |
2042 | | - if mode == "sso": |
2043 | | - return hide, {}, hide |
2044 | | - return hide, hide, {} |
| 2080 | + show = {} |
| 2081 | + return ( |
| 2082 | + show if mode == "obo" else hide, |
| 2083 | + show if mode == "sp" else hide, |
| 2084 | + show if mode == "profile" else hide, |
| 2085 | + show if mode == "sso" else hide, |
| 2086 | + show if mode == "custom" else hide, |
| 2087 | + ) |
2045 | 2088 |
|
2046 | 2089 |
|
2047 | 2090 | # 5. Show auth type hint when profile changes |
@@ -2092,6 +2135,54 @@ def apply_connection(n_clicks, mode, profile, sso_host, custom_host, custom_toke |
2092 | 2135 | if not n_clicks: |
2093 | 2136 | return no_update, no_update, no_update |
2094 | 2137 |
|
| 2138 | + if mode == "obo": |
| 2139 | + # On-Behalf-Of — uses X-Forwarded-Access-Token header injected by |
| 2140 | + # Databricks Apps proxy when User Authorization is enabled and |
| 2141 | + # scopes are configured for the app. |
| 2142 | + obo_token = flask_request.headers.get("X-Forwarded-Access-Token") |
| 2143 | + if not obo_token: |
| 2144 | + return no_update, html.Div([ |
| 2145 | + html.Span("No OBO token available.", className="text-danger"), |
| 2146 | + html.Br(), |
| 2147 | + html.Span( |
| 2148 | + "To enable OBO: 1) A workspace admin must enable " |
| 2149 | + "User Authorization in workspace settings. " |
| 2150 | + "2) Add scopes (e.g. 'all-apis') to this app in the " |
| 2151 | + "Databricks Apps UI → Configure → Add scope. " |
| 2152 | + "3) Restart the app.", |
| 2153 | + className="text-muted", |
| 2154 | + style={"fontSize": "11px"}, |
| 2155 | + ), |
| 2156 | + ]), no_update |
| 2157 | + host = get_host() |
| 2158 | + r = make_api_call("GET", "/api/2.0/preview/scim/v2/Me", obo_token, host) |
| 2159 | + if r["success"]: |
| 2160 | + user_label = r["data"].get("displayName", r["data"].get("userName", "User")) |
| 2161 | + return {"mode": "obo"}, html.Span([ |
| 2162 | + html.I(className="bi bi-check-circle-fill me-1 text-success"), |
| 2163 | + f"Connected as {user_label} (OBO)", |
| 2164 | + ]), no_update |
| 2165 | + return no_update, html.Span( |
| 2166 | + f"OBO token present but API call failed: {r.get('error', 'unknown error')}", |
| 2167 | + className="text-danger", |
| 2168 | + ), no_update |
| 2169 | + |
| 2170 | + if mode == "sp": |
| 2171 | + # Service Principal — validate we can get a token |
| 2172 | + token = get_sp_token() |
| 2173 | + if not token: |
| 2174 | + return no_update, html.Span( |
| 2175 | + "Could not obtain SP token — check DATABRICKS_CLIENT_ID / SECRET env vars.", |
| 2176 | + className="text-danger", |
| 2177 | + ), no_update |
| 2178 | + host = get_host() |
| 2179 | + r = make_api_call("GET", "/api/2.0/preview/scim/v2/Me", token, host) |
| 2180 | + sp_label = r["data"].get("displayName", "Service Principal") if r["success"] else "Service Principal" |
| 2181 | + return {"mode": "sp"}, html.Span([ |
| 2182 | + html.I(className="bi bi-check-circle-fill me-1 text-success"), |
| 2183 | + f"Connected as {sp_label}", |
| 2184 | + ]), no_update |
| 2185 | + |
2095 | 2186 | if mode == "profile": |
2096 | 2187 | if not profile: |
2097 | 2188 | return no_update, html.Span("No profile selected.", className="text-danger"), no_update |
|
0 commit comments