Skip to content

Commit 61988c0

Browse files
authored
Pipeline run API token fixes and improvements (#3242)
* Remove the workload tokens expiry * Allow clients to use generic API tokens instead of workload API tokens with pipeline runs * Actually use the set expiration time in the endpoint
1 parent e72aef7 commit 61988c0

File tree

7 files changed

+107
-35
lines changed

7 files changed

+107
-35
lines changed

src/zenml/config/server_config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
DEFAULT_ZENML_SERVER_DEVICE_AUTH_POLLING,
2828
DEFAULT_ZENML_SERVER_DEVICE_AUTH_TIMEOUT,
2929
DEFAULT_ZENML_SERVER_GENERIC_API_TOKEN_LIFETIME,
30+
DEFAULT_ZENML_SERVER_GENERIC_API_TOKEN_MAX_LIFETIME,
3031
DEFAULT_ZENML_SERVER_LOGIN_RATE_LIMIT_DAY,
3132
DEFAULT_ZENML_SERVER_LOGIN_RATE_LIMIT_MINUTE,
3233
DEFAULT_ZENML_SERVER_MAX_DEVICE_AUTH_ATTEMPTS,
@@ -269,6 +270,9 @@ class ServerConfiguration(BaseModel):
269270
generic_api_token_lifetime: PositiveInt = (
270271
DEFAULT_ZENML_SERVER_GENERIC_API_TOKEN_LIFETIME
271272
)
273+
generic_api_token_max_lifetime: PositiveInt = (
274+
DEFAULT_ZENML_SERVER_GENERIC_API_TOKEN_MAX_LIFETIME
275+
)
272276

273277
external_login_url: Optional[str] = None
274278
external_user_info_url: Optional[str] = None

src/zenml/constants.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,9 @@ def handle_int_env_var(var: str, default: int = 0) -> int:
170170
ENV_ZENML_IGNORE_FAILURE_HOOK = "ZENML_IGNORE_FAILURE_HOOK"
171171
ENV_ZENML_CUSTOM_SOURCE_ROOT = "ZENML_CUSTOM_SOURCE_ROOT"
172172
ENV_ZENML_WHEEL_PACKAGE_NAME = "ZENML_WHEEL_PACKAGE_NAME"
173+
ENV_ZENML_PIPELINE_RUN_API_TOKEN_EXPIRATION = (
174+
"ZENML_PIPELINE_API_TOKEN_EXPIRATION"
175+
)
173176

174177
# ZenML Server environment variables
175178
ENV_ZENML_SERVER_PREFIX = "ZENML_SERVER_"
@@ -268,6 +271,9 @@ def handle_int_env_var(var: str, default: int = 0) -> int:
268271
DEFAULT_ZENML_SERVER_LOGIN_RATE_LIMIT_MINUTE = 5
269272
DEFAULT_ZENML_SERVER_LOGIN_RATE_LIMIT_DAY = 1000
270273
DEFAULT_ZENML_SERVER_GENERIC_API_TOKEN_LIFETIME = 60 * 60 # 1 hour
274+
DEFAULT_ZENML_SERVER_GENERIC_API_TOKEN_MAX_LIFETIME = (
275+
60 * 60 * 24 * 7
276+
) # 7 days
271277

272278
DEFAULT_ZENML_SERVER_SECURE_HEADERS_HSTS = (
273279
"max-age=63072000; includeSubdomains"
@@ -466,3 +472,7 @@ def handle_int_env_var(var: str, default: int = 0) -> int:
466472

467473

468474
STACK_DEPLOYMENT_API_TOKEN_EXPIRATION = 60 * 6 # 6 hours
475+
476+
ZENML_PIPELINE_RUN_API_TOKEN_EXPIRATION = handle_int_env_var(
477+
ENV_ZENML_PIPELINE_RUN_API_TOKEN_EXPIRATION, default=0
478+
)

src/zenml/orchestrators/utils.py

Lines changed: 55 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,12 @@
2626
ENV_ZENML_ACTIVE_STACK_ID,
2727
ENV_ZENML_ACTIVE_WORKSPACE_ID,
2828
ENV_ZENML_DISABLE_CREDENTIALS_DISK_CACHING,
29+
ENV_ZENML_PIPELINE_RUN_API_TOKEN_EXPIRATION,
2930
ENV_ZENML_SERVER,
3031
ENV_ZENML_STORE_PREFIX,
32+
ZENML_PIPELINE_RUN_API_TOKEN_EXPIRATION,
3133
)
32-
from zenml.enums import AuthScheme, StackComponentType, StoreType
34+
from zenml.enums import APITokenType, AuthScheme, StackComponentType, StoreType
3335
from zenml.logger import get_logger
3436
from zenml.stack import StackComponent
3537

@@ -137,37 +139,63 @@ def get_config_environment_vars(
137139
url = global_config.store_configuration.url
138140
api_token = credentials_store.get_token(url, allow_expired=False)
139141
if schedule_id or pipeline_run_id or step_run_id:
140-
# When connected to an authenticated ZenML server, if a schedule ID,
141-
# pipeline run ID or step run ID is supplied, we need to fetch a new
142-
# workload API token scoped to the schedule, pipeline run or step
143-
# run.
144142
assert isinstance(global_config.zen_store, RestZenStore)
145143

146-
# If only a schedule is given, the pipeline run credentials will
147-
# be valid for the entire duration of the schedule.
148-
api_key = credentials_store.get_api_key(url)
149-
if not api_key and not pipeline_run_id and not step_run_id:
144+
# The user has the option to manually set an expiration for the API
145+
# token generated for a pipeline run. In this case, we generate a new
146+
# generic API token that will be valid for the indicated duration.
147+
if (
148+
pipeline_run_id
149+
and ZENML_PIPELINE_RUN_API_TOKEN_EXPIRATION != 0
150+
):
150151
logger.warning(
151-
"An API token without an expiration time will be generated "
152-
"and used to run this pipeline on a schedule. This is very "
153-
"insecure because the API token will be valid for the "
154-
"entire lifetime of the schedule and can be used to access "
155-
"your user account if accidentally leaked. When deploying "
156-
"a pipeline on a schedule, it is strongly advised to use a "
157-
"service account API key to authenticate to the ZenML "
158-
"server instead of your regular user account. For more "
159-
"information, see "
160-
"https://docs.zenml.io/how-to/connecting-to-zenml/connect-with-a-service-account"
152+
f"An unscoped API token will be generated for this pipeline "
153+
f"run that will expire after "
154+
f"{ZENML_PIPELINE_RUN_API_TOKEN_EXPIRATION} "
155+
f"seconds instead of being scoped to the pipeline run "
156+
f"and not having an expiration time. This is more insecure "
157+
f"because the API token will remain valid even after the "
158+
f"pipeline run completes its execution. This option has "
159+
"been explicitly enabled by setting the "
160+
f"{ENV_ZENML_PIPELINE_RUN_API_TOKEN_EXPIRATION} environment "
161+
f"variable"
162+
)
163+
new_api_token = global_config.zen_store.get_api_token(
164+
token_type=APITokenType.GENERIC,
165+
expires_in=ZENML_PIPELINE_RUN_API_TOKEN_EXPIRATION,
161166
)
162167

163-
# The schedule, pipeline run or step run credentials are scoped to
164-
# the schedule, pipeline run or step run and will only be valid for
165-
# the duration of the schedule/pipeline run/step run.
166-
new_api_token = global_config.zen_store.get_api_token(
167-
schedule_id=schedule_id,
168-
pipeline_run_id=pipeline_run_id,
169-
step_run_id=step_run_id,
170-
)
168+
else:
169+
# If a schedule ID, pipeline run ID or step run ID is supplied,
170+
# we need to fetch a new workload API token scoped to the
171+
# schedule, pipeline run or step run.
172+
173+
# If only a schedule is given, the pipeline run credentials will
174+
# be valid for the entire duration of the schedule.
175+
api_key = credentials_store.get_api_key(url)
176+
if not api_key and not pipeline_run_id and not step_run_id:
177+
logger.warning(
178+
"An API token without an expiration time will be generated "
179+
"and used to run this pipeline on a schedule. This is very "
180+
"insecure because the API token will be valid for the "
181+
"entire lifetime of the schedule and can be used to access "
182+
"your user account if accidentally leaked. When deploying "
183+
"a pipeline on a schedule, it is strongly advised to use a "
184+
"service account API key to authenticate to the ZenML "
185+
"server instead of your regular user account. For more "
186+
"information, see "
187+
"https://docs.zenml.io/how-to/connecting-to-zenml/connect-with-a-service-account"
188+
)
189+
190+
# The schedule, pipeline run or step run credentials are scoped to
191+
# the schedule, pipeline run or step run and will only be valid for
192+
# the duration of the schedule/pipeline run/step run.
193+
new_api_token = global_config.zen_store.get_api_token(
194+
token_type=APITokenType.WORKLOAD,
195+
schedule_id=schedule_id,
196+
pipeline_run_id=pipeline_run_id,
197+
step_run_id=step_run_id,
198+
)
171199

172200
environment_vars[ENV_ZENML_STORE_PREFIX + "API_TOKEN"] = (
173201
new_api_token

src/zenml/zen_server/auth.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -821,7 +821,10 @@ def generate_access_token(
821821
response: The FastAPI response object.
822822
device: The device used for authentication.
823823
api_key: The service account API key used for authentication.
824-
expires_in: The number of seconds until the token expires.
824+
expires_in: The number of seconds until the token expires. If not set,
825+
the default value is determined automatically based on the server
826+
configuration and type of token. If set to 0, the token will not
827+
expire.
825828
schedule_id: The ID of the schedule to scope the token to.
826829
pipeline_run_id: The ID of the pipeline run to scope the token to.
827830
step_run_id: The ID of the step run to scope the token to.
@@ -835,7 +838,9 @@ def generate_access_token(
835838
# according to the values configured in the server config. Device tokens are
836839
# handled separately from regular user tokens.
837840
expires: Optional[datetime] = None
838-
if expires_in:
841+
if expires_in == 0:
842+
expires_in = None
843+
elif expires_in is not None:
839844
expires = datetime.utcnow() + timedelta(seconds=expires_in)
840845
elif device:
841846
# If a device was used for authentication, the token will expire

src/zenml/zen_server/routers/auth_endpoints.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,7 @@ def device_authorization(
452452
@handle_exceptions
453453
def api_token(
454454
token_type: APITokenType = APITokenType.GENERIC,
455+
expires_in: Optional[int] = None,
455456
schedule_id: Optional[UUID] = None,
456457
pipeline_run_id: Optional[UUID] = None,
457458
step_run_id: Optional[UUID] = None,
@@ -463,7 +464,8 @@ def api_token(
463464
of API tokens are supported:
464465
465466
* Generic API token: This token is short-lived and can be used for
466-
generic automation tasks.
467+
generic automation tasks. The expiration can be set by the user, but the
468+
server will impose a maximum expiration time.
467469
* Workload API token: This token is scoped to a specific pipeline run, step
468470
run or schedule and is used by pipeline workloads to authenticate with the
469471
server. A pipeline run ID, step run ID or schedule ID must be provided and
@@ -475,6 +477,10 @@ def api_token(
475477
476478
Args:
477479
token_type: The type of API token to generate.
480+
expires_in: The expiration time of the generic API token in seconds.
481+
If not set, the server will use the default expiration time for
482+
generic API tokens. The server also imposes a maximum expiration
483+
time.
478484
schedule_id: The ID of the schedule to scope the workload API token to.
479485
pipeline_run_id: The ID of the pipeline run to scope the workload API
480486
token to.
@@ -502,9 +508,19 @@ def api_token(
502508

503509
config = server_config()
504510

511+
if not expires_in:
512+
expires_in = config.generic_api_token_lifetime
513+
514+
if expires_in > config.generic_api_token_max_lifetime:
515+
raise ValueError(
516+
f"The maximum expiration time for generic API tokens allowed "
517+
f"by this server is {config.generic_api_token_max_lifetime} "
518+
"seconds."
519+
)
520+
505521
return generate_access_token(
506522
user_id=token.user_id,
507-
expires_in=config.generic_api_token_lifetime,
523+
expires_in=expires_in,
508524
).access_token
509525

510526
verify_permission(
@@ -611,4 +627,6 @@ def api_token(
611627
schedule_id=schedule_id,
612628
pipeline_run_id=pipeline_run_id,
613629
step_run_id=step_run_id,
630+
# Never expire the token
631+
expires_in=0,
614632
).access_token

src/zenml/zen_server/template_execution/utils.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,13 +122,15 @@ def run_template(
122122
placeholder_run = create_placeholder_run(deployment=new_deployment)
123123
assert placeholder_run
124124

125-
# We create an API token scoped to the pipeline run
125+
# We create an API token scoped to the pipeline run that never expires
126126
api_token = generate_access_token(
127127
user_id=auth_context.user.id,
128128
pipeline_run_id=placeholder_run.id,
129129
# Keep the original API key or device scopes, if any
130130
api_key=auth_context.api_key,
131131
device=auth_context.device,
132+
# Never expire the token
133+
expires_in=0,
132134
).access_token
133135

134136
environment = {

src/zenml/zen_stores/rest_zen_store.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3873,13 +3873,17 @@ def delete_authorized_device(self, device_id: UUID) -> None:
38733873

38743874
def get_api_token(
38753875
self,
3876+
token_type: APITokenType = APITokenType.WORKLOAD,
3877+
expires_in: Optional[int] = None,
38763878
schedule_id: Optional[UUID] = None,
38773879
pipeline_run_id: Optional[UUID] = None,
38783880
step_run_id: Optional[UUID] = None,
38793881
) -> str:
3880-
"""Get an API token for a workload.
3882+
"""Get an API token.
38813883
38823884
Args:
3885+
token_type: The type of the token to get.
3886+
expires_in: The time in seconds until the token expires.
38833887
schedule_id: The ID of the schedule to get a token for.
38843888
pipeline_run_id: The ID of the pipeline run to get a token for.
38853889
step_run_id: The ID of the step run to get a token for.
@@ -3891,9 +3895,10 @@ def get_api_token(
38913895
ValueError: if the server response is not valid.
38923896
"""
38933897
params: Dict[str, Any] = {
3894-
# Python clients may only request workload tokens.
3895-
"token_type": APITokenType.WORKLOAD.value,
3898+
"token_type": token_type.value,
38963899
}
3900+
if expires_in:
3901+
params["expires_in"] = expires_in
38973902
if schedule_id:
38983903
params["schedule_id"] = schedule_id
38993904
if pipeline_run_id:

0 commit comments

Comments
 (0)