Skip to content

Commit b417ab1

Browse files
authored
feat: use service account for tiled insertion (#1398)
1 parent be81937 commit b417ab1

20 files changed

Lines changed: 395 additions & 53 deletions

File tree

helm/blueapi/config_schema.json

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,28 @@
440440
"type": "object",
441441
"$id": "ScratchRepository"
442442
},
443+
"ServiceAccount": {
444+
"additionalProperties": false,
445+
"properties": {
446+
"client_id": {
447+
"default": "",
448+
"description": "Service account client ID",
449+
"title": "Client Id",
450+
"type": "string"
451+
},
452+
"client_secret": {
453+
"default": "",
454+
"description": "Service account client secret",
455+
"format": "password",
456+
"title": "Client Secret",
457+
"type": "string",
458+
"writeOnly": true
459+
}
460+
},
461+
"title": "ServiceAccount",
462+
"type": "object",
463+
"$id": "ServiceAccount"
464+
},
443465
"StompConfig": {
444466
"additionalProperties": false,
445467
"description": "Config for connecting to stomp broker",
@@ -491,17 +513,21 @@
491513
"title": "Url",
492514
"type": "string"
493515
},
494-
"api_key": {
516+
"authentication": {
495517
"anyOf": [
496518
{
497519
"type": "string"
498520
},
521+
{
522+
"$ref": "ServiceAccount"
523+
},
499524
{
500525
"type": "null"
501526
}
502527
],
503528
"default": null,
504-
"title": "Api Key"
529+
"description": "Tiled Authentication can be API_KEY or OIDC Service account",
530+
"title": "Authentication"
505531
}
506532
},
507533
"title": "TiledConfig",

helm/blueapi/values.schema.json

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -872,6 +872,28 @@
872872
},
873873
"additionalProperties": false
874874
},
875+
"ServiceAccount": {
876+
"$id": "ServiceAccount",
877+
"title": "ServiceAccount",
878+
"type": "object",
879+
"properties": {
880+
"client_id": {
881+
"title": "Client Id",
882+
"description": "Service account client ID",
883+
"default": "",
884+
"type": "string"
885+
},
886+
"client_secret": {
887+
"title": "Client Secret",
888+
"description": "Service account client secret",
889+
"writeOnly": true,
890+
"default": "",
891+
"type": "string",
892+
"format": "password"
893+
}
894+
},
895+
"additionalProperties": false
896+
},
875897
"StompConfig": {
876898
"$id": "StompConfig",
877899
"title": "StompConfig",
@@ -910,12 +932,16 @@
910932
"title": "TiledConfig",
911933
"type": "object",
912934
"properties": {
913-
"api_key": {
914-
"title": "Api Key",
935+
"authentication": {
936+
"title": "Authentication",
937+
"description": "Tiled Authentication can be API_KEY or OIDC Service account",
915938
"anyOf": [
916939
{
917940
"type": "string"
918941
},
942+
{
943+
"$ref": "ServiceAccount"
944+
},
919945
{
920946
"type": "null"
921947
}

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ classifiers = [
1313
]
1414
description = "Lightweight bluesky-as-a-service wrapper application. Also usable as a library."
1515
dependencies = [
16-
"tiled[client]>=0.2.3",
16+
"tiled[client]>=0.2.4",
1717
"bluesky[plotting]>=1.14.0", # plotting includes matplotlib, required for BestEffortCallback in run plans
1818
"ophyd-async>=0.13.5",
1919
"aioca",
@@ -68,7 +68,8 @@ dev = [
6868
"mock",
6969
"jwcrypto",
7070
"deepdiff",
71-
"tiled[minimal-server]>=0.2.3", # For system-test of dls.py
71+
"tiled[minimal-server]>=0.2.4", # For system-test of dls.py
72+
"respx"
7273
]
7374

7475
[project.scripts]

src/blueapi/config.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
BaseModel,
1717
Field,
1818
HttpUrl,
19+
SecretStr,
1920
TypeAdapter,
2021
UrlConstraints,
2122
ValidationError,
@@ -107,13 +108,26 @@ class StompConfig(BlueapiBaseModel):
107108
)
108109

109110

111+
class ServiceAccount(BlueapiBaseModel):
112+
client_id: str = Field(description="Service account client ID", default="")
113+
client_secret: SecretStr = Field(
114+
description="Service account client secret", default=SecretStr("")
115+
)
116+
token_url: SkipJsonSchema[str] = Field(
117+
description="Field overridden by OIDCConfig.token_endpoint", default=""
118+
)
119+
120+
110121
class TiledConfig(BlueapiBaseModel):
111122
enabled: bool = Field(
112123
description="True if blueapi should forward data to a Tiled instance",
113124
default=False,
114125
)
115126
url: HttpUrl = HttpUrl("http://localhost:8407")
116-
api_key: str | None = os.environ.get("TILED_SINGLE_USER_API_KEY", None)
127+
authentication: str | ServiceAccount | None = Field(
128+
description="Tiled Authentication can be API_KEY or OIDC Service account",
129+
default=os.environ.get("TILED_SINGLE_USER_API_KEY", None),
130+
)
117131

118132

119133
class WorkerEventConfig(BlueapiBaseModel):

src/blueapi/core/context.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
DodalSource,
3131
EnvironmentConfig,
3232
PlanSource,
33+
ServiceAccount,
3334
TiledConfig,
3435
)
3536
from blueapi.core.protocols import DeviceManager
@@ -186,6 +187,13 @@ def _update_scan_num(md: dict[str, Any]) -> int:
186187
"Tiled has been configured but `instrument` metadata is not set - "
187188
"this field is required to make authorization decisions."
188189
)
190+
if isinstance(tiled_conf.authentication, ServiceAccount):
191+
if configuration.oidc is None:
192+
raise InvalidConfigError(
193+
"Tiled has been configured but oidc configuration is missing "
194+
"this field is required to make authorization decisions."
195+
)
196+
tiled_conf.authentication.token_url = configuration.oidc.token_endpoint
189197
self.tiled_conf = tiled_conf
190198

191199
def find_device(self, addr: str | list[str]) -> Device | None:

src/blueapi/service/authentication.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import base64
44
import os
5+
import threading
56
import time
67
import webbrowser
78
from abc import ABC, abstractmethod
@@ -10,12 +11,13 @@
1011
from pathlib import Path
1112
from typing import Any, cast
1213

14+
import httpx
1315
import jwt
1416
import requests
1517
from pydantic import TypeAdapter
1618
from requests.auth import AuthBase
1719

18-
from blueapi.config import OIDCConfig
20+
from blueapi.config import OIDCConfig, ServiceAccount
1921
from blueapi.service.model import Cache
2022

2123
DEFAULT_CACHE_DIR = "~/.cache/"
@@ -239,3 +241,28 @@ def __call__(self, request):
239241
if self.token:
240242
request.headers["Authorization"] = f"Bearer {self.token}"
241243
return request
244+
245+
246+
class TiledAuth(httpx.Auth):
247+
def __init__(self, tiled_auth: ServiceAccount):
248+
if tiled_auth.token_url == "":
249+
raise RuntimeError("Token URL is not set please check oidc config")
250+
self._tiled_auth: ServiceAccount = tiled_auth
251+
self._sync_lock = threading.RLock()
252+
253+
def get_access_token(self):
254+
with self._sync_lock:
255+
response = httpx.post(
256+
self._tiled_auth.token_url,
257+
data={
258+
"client_id": self._tiled_auth.client_id,
259+
"client_secret": self._tiled_auth.client_secret.get_secret_value(),
260+
"grant_type": "client_credentials",
261+
},
262+
)
263+
response.raise_for_status()
264+
return response.json().get("access_token")
265+
266+
def sync_auth_flow(self, request):
267+
request.headers["Authorization"] = f"Bearer {self.get_access_token()}"
268+
yield request

src/blueapi/service/interface.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88
from tiled.client import from_uri
99

1010
from blueapi.cli.scratch import get_python_environment
11-
from blueapi.config import ApplicationConfig, OIDCConfig, StompConfig
11+
from blueapi.config import ApplicationConfig, OIDCConfig, ServiceAccount, StompConfig
1212
from blueapi.core.context import BlueskyContext
1313
from blueapi.core.event import EventStream
1414
from blueapi.log import set_up_logging
15+
from blueapi.service.authentication import TiledAuth
1516
from blueapi.service.model import (
1617
DeviceModel,
1718
PlanModel,
@@ -188,11 +189,18 @@ def begin_task(
188189

189190
if tiled_config := active_context.tiled_conf:
190191
# Tiled queries the root node, so must create an authorized client
191-
tiled_client = from_uri(
192-
str(tiled_config.url),
193-
api_key=tiled_config.api_key,
194-
headers=pass_through_headers,
195-
)
192+
if isinstance(tiled_config.authentication, ServiceAccount):
193+
tiled_client = from_uri(
194+
str(tiled_config.url),
195+
auth=TiledAuth(tiled_auth=tiled_config.authentication),
196+
)
197+
else:
198+
tiled_client = from_uri(
199+
str(tiled_config.url),
200+
api_key=tiled_config.authentication,
201+
headers=pass_through_headers,
202+
)
203+
196204
tiled_writer_token = active_context.run_engine.subscribe(
197205
TiledWriter(tiled_client, batch_size=1)
198206
)

tests/system_tests/compose.yaml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,16 +39,16 @@ services:
3939
retries: 10
4040
start_period: 30s
4141

42-
tiled:
43-
image: ghcr.io/bluesky/tiled:0.2.3
42+
tiled:
43+
image: ghcr.io/bluesky/tiled:0.2.4
4444
network_mode: host
4545
environment:
4646
- PYTHONPATH=/deploy/
47-
volumes:
47+
volumes:
4848
- ./services/tiled_config:/deploy/config
49-
command: ["tiled", "serve", "config", "--host", "0.0.0.0", "--port", "8407"]
50-
depends_on:
51-
keycloak:
49+
command: ["tiled", "serve", "config", "--host", "0.0.0.0", "--port", "8407"]
50+
depends_on:
51+
keycloak:
5252
condition: service_healthy
5353

5454
opa:

tests/system_tests/config.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ numtracker:
1818
tiled:
1919
enabled: true
2020
url: http://localhost:8407/api/v1
21+
authentication:
22+
client_id: "tiled-writer"
23+
client_secret: "secret"
2124
oidc:
2225
well_known_url: "http://localhost:8081/realms/master/.well-known/openid-configuration"
2326
client_id: "ixx-cli-blueapi"
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"protocolMappers": [
3+
{
4+
"name": "beamline",
5+
"protocol": "openid-connect",
6+
"protocolMapper": "oidc-hardcoded-claim-mapper",
7+
"consentRequired": false,
8+
"config": {
9+
"introspection.token.claim": "true",
10+
"claim.value": "adsim",
11+
"userinfo.token.claim": "true",
12+
"id.token.claim": "true",
13+
"lightweight.claim": "false",
14+
"access.token.claim": "true",
15+
"claim.name": "beamline",
16+
"jsonType.label": "String",
17+
"access.tokenResponse.claim": "false"
18+
}
19+
},
20+
{
21+
"name": "tiled",
22+
"protocol": "openid-connect",
23+
"protocolMapper": "oidc-audience-mapper",
24+
"consentRequired": false,
25+
"config": {
26+
"id.token.claim": "false",
27+
"lightweight.claim": "false",
28+
"access.token.claim": "true",
29+
"introspection.token.claim": "true",
30+
"included.custom.audience": "tiled-writer"
31+
}
32+
}
33+
]
34+
}

0 commit comments

Comments
 (0)