Skip to main content
Version: v1.2.7

Secure deployments

Deployments authenticate scoring requests through one or more SecurityOptions entries. Carrying two or more entries lets you mix mechanisms (for example, OIDC alongside a passphrase) and rotate credentials without downtime.

Prerequisites​

Before you begin, complete the following steps:

  1. Import the necessary Python packages. For instructions, see Step 1: Import the required packages.
  2. Connect to H2O MLOps. For instructions, see Connect to H2O MLOps.
  3. Create a workspace. For instructions, see Create a workspace.
  4. Create an experiment. For instructions, see Create an experiment.
  5. Create a model and register the experiment with it. For instructions, see Register an experiment with a model.

The examples below assume workspace, comp_opts, model, and scoring_runtime are already defined.

Security types​

The table below describes each SecurityType value, when to use it, and any trade-offs to consider.

SecurityTypeUse whenNotes
DISABLEDTesting or internal-only deployments that require no authentication.Endpoint is publicly accessible. Do not use in production.
PLAIN_PASSPHRASEYou accept passphrase exposure in transit.The passphrase travels in clear text. Less secure than the hashed variant.
HASHED_PASSPHRASEYou want passphrase authentication with stronger server-side storage.Uses PBKDF2 hashing. The actual secret does not retransmit after create.
OIDC_AUTHYou need token-based authentication integrated with your identity provider.Requires server-side configuration. See Endpoint security.
API_KEYYou need a high-entropy, server-generated service-to-service credential.Server returns the plaintext exactly once on the create response. See Rotate an API_KEY.
note

Not all security types work in every environment. Cluster operators configure the allowed types.

To check the allowed types using the H2O MLOps Python client, run: mlops.configs.allowed_security_types.

API_KEY requires "API_KEY" in securityOptions.activated in your values.yaml. Without it, the server rejects API_KEY entries with "unsupported api_key security option in current environment".

Multi-entry security model​

A deployment can carry one or more SecurityOptions entries. At scoring time, any single entry that passes authenticates the request. The server evaluates entries independently.

A naming convention to know up front: the read-side property is deployment.securities_options (plural), and the keyword argument on create() and update() is security_options= (singular), even when you pass a list. Avoid the deprecated singular deployment.security_options, which returns only the first entry.

Each entry carries an optional name (a human-readable label) and a server-assigned uid (stable identifier). When updating, read entries from deployment.securities_options to get the uid values you need for EDIT and DELETE actions. See the SecurityOptions field reference for the full field list.

Read the security entries on a deployment​

Use the securities_options property (plural) to read the current list of entries. Code examples in this page reuse the _print_entries(dep) helper defined below:

def _print_entries(dep):
for s in dep.securities_options:
print(f" uid={s.uid!r} type={s.security_type.name} name={s.name!r}")


_print_entries(deployment)
Deprecated: security_options (singular)

deployment.security_options returns only the first entry and emits a DeprecationWarning:

deployment.security_options is deprecated and returns only the first security entry. Use deployment.securities_options instead.

Use deployment.securities_options (plural) in all new code.

Constraints on the entry list​

The server enforces these constraints on the entry list. They apply to both create and update calls.

  • DISABLED cannot coexist with other entries. The server rejects a list that mixes DISABLED with any other security type: "disabled_security cannot be combined with other security options".
  • Combined cap of 100 passphrase and API_KEY entries. The server caps the combined count of PLAIN_PASSPHRASE, HASHED_PASSPHRASE, and API_KEY entries on a single deployment at 100. OIDC entries do not count toward this cap. Exceeding the cap returns "number of passphrase/api_key entries must not exceed 100".
  • A deployment must carry at least one entry. The server rejects an update that would leave the deployment with no entries: "missing authentication type". To switch a secured deployment to no authentication, replace the entry list with a single DISABLED entry rather than emptying it.

Create a deployment with security​

Single entry (shorthand)​

Pass a single SecurityOptions instance to create a one-entry secured deployment. The read-side returns the entry with a server-assigned uid.

from h2o_mlops.options import SecurityOptions, CompositionOptions
from h2o_mlops.types import DeploymentModeType, SecurityType

sec_single = SecurityOptions(
security_type=SecurityType.PLAIN_PASSPHRASE,
passphrase="single-entry-secret",
name="default-passphrase",
)

dep_single = workspace.deployments.create(
name="security-demo-single",
composition_options=[comp_opts],
mode=DeploymentModeType.SINGLE_MODEL,
security_options=sec_single,
)

_print_entries(dep_single)

# Scoring uses the passphrase as auth value:
dep_single.scorer.score(
auth_value="single-entry-secret",
payload=dep_single.scorer.sample_request(auth_value="single-entry-secret"),
)

Two or more entries​

Pass a list of SecurityOptions instances. Each entry independently authenticates incoming requests.

from h2o_mlops.options import SecurityOptions, CompositionOptions
from h2o_mlops.types import DeploymentModeType, SecurityType

sec_oidc = SecurityOptions(
security_type=SecurityType.OIDC_AUTH,
name="company-oidc",
)
sec_pass = SecurityOptions(
security_type=SecurityType.HASHED_PASSPHRASE,
passphrase="initial-secret",
name="hashed-passphrase",
)

dep_multi = workspace.deployments.create(
name="security-demo-multi",
composition_options=[comp_opts],
mode=DeploymentModeType.SINGLE_MODEL,
security_options=[sec_oidc, sec_pass],
)

_print_entries(dep_multi)

# Either credential is accepted at scoring time:
dep_multi.scorer.score(
auth_value="initial-secret",
payload=dep_multi.scorer.sample_request(auth_value="initial-secret"),
)
dep_multi.scorer.score(
auth_value="<oidc-bearer-token>",
payload=dep_multi.scorer.sample_request(auth_value="<oidc-bearer-token>"),
)

Create with API_KEY​

The server generates the API key. Set security_type=SecurityType.API_KEY and leave api_key unset. The client raises ValueError if you try to supply one.

from h2o_mlops.options import SecurityOptions, CompositionOptions
from h2o_mlops.types import DeploymentModeType, SecurityType


sec_api_key = SecurityOptions(
security_type=SecurityType.API_KEY,
name="service-key",
)

dep_api_key = workspace.deployments.create(
name="security-demo-api-key",
composition_options=[comp_opts],
mode=DeploymentModeType.SINGLE_MODEL,
security_options=sec_api_key,
)

# Read the plaintext now (see the caution below).
plaintext = dep_api_key.securities_options[0].api_key
print(f"API key (save this now): {plaintext!r}")

# api_key_prefix is safe to log on every read.
prefix = dep_api_key.securities_options[0].api_key_prefix
print(f"API key prefix: {prefix!r}")

# Use the plaintext as auth_value for scoring:
dep_api_key.scorer.score(
auth_value=plaintext,
payload=dep_api_key.scorer.sample_request(auth_value=plaintext),
)
caution

The create response carries api_key in plaintext exactly once. Later reads return None for api_key; only api_key_prefix is available for identification. Store the plaintext securely before proceeding.

Update security on an existing deployment​

Each entry in an update sets action (ADD, EDIT, or DELETE) to indicate the per-entry operation. All operations in the list bundle into a single PATCH and trigger a single redeployment.

ADD​

Insert a new entry.

from h2o_mlops.types import SecurityAction

dep_multi.update(
security_options=[
SecurityOptions(
action=SecurityAction.ADD,
security_type=SecurityType.PLAIN_PASSPHRASE,
passphrase="granular-added-secret",
name="granular-add",
),
],
)

EDIT to rotate or rename​

Fetch the current entries to get their server-assigned uid values, then submit both edits in one call.

from h2o_mlops.types import SecurityAction


entries = dep_multi.securities_options
granular_added = next(s for s in entries if s.name == "granular-add")
oidc_entry = next(s for s in entries if s.security_type == SecurityType.OIDC_AUTH)

dep_multi.update(
security_options=[
SecurityOptions(
action=SecurityAction.EDIT,
uid=granular_added.uid,
security_type=SecurityType.PLAIN_PASSPHRASE,
passphrase="granular-rotated-secret", # supplying a value re-hashes
name="granular-add",
),
SecurityOptions(
action=SecurityAction.EDIT,
uid=oidc_entry.uid,
security_type=SecurityType.OIDC_AUTH,
name="company-oidc-renamed", # rename only
),
],
)

EDIT to preserve a stored hash​

Set passphrase=None on a passphrase-type EDIT to keep the existing hash and change only metadata such as name.

from h2o_mlops.types import SecurityAction


hashed = next(
s for s in dep_multi.securities_options
if s.security_type == SecurityType.HASHED_PASSPHRASE
)

dep_multi.update(
security_options=[
SecurityOptions(
action=SecurityAction.EDIT,
uid=hashed.uid,
security_type=SecurityType.HASHED_PASSPHRASE,
passphrase=None, # keep the stored hash
name="hashed-passphrase-renamed",
),
],
)

# Original secret still works:
dep_multi.scorer.score(
auth_value="initial-secret",
payload=dep_multi.scorer.sample_request(auth_value="initial-secret"),
)

DELETE​

from h2o_mlops.types import SecurityAction


target = next(
s for s in dep_multi.securities_options if s.name == "granular-add"
)
dep_multi.update(
security_options=[
SecurityOptions(action=SecurityAction.DELETE, uid=target.uid),
],
)

Combined operations in one PATCH​

Bundle ADD, EDIT, and DELETE into a single call for atomic rotation: one PATCH, one redeployment. The deployment never loses the entries that remain valid.

ADD + EDIT + DELETE:

from h2o_mlops.types import SecurityAction


entries = dep_multi.securities_options
oidc = next(s for s in entries if s.security_type == SecurityType.OIDC_AUTH)
hashed = next(s for s in entries if s.security_type == SecurityType.HASHED_PASSPHRASE)

dep_multi.update(
security_options=[
SecurityOptions(
action=SecurityAction.ADD,
security_type=SecurityType.PLAIN_PASSPHRASE,
passphrase="combined-added-secret",
name="combined-add",
),
SecurityOptions(
action=SecurityAction.EDIT,
uid=oidc.uid,
security_type=SecurityType.OIDC_AUTH,
name="company-oidc-final",
),
SecurityOptions(action=SecurityAction.DELETE, uid=hashed.uid),
],
)

Granular security combined with kubernetes_options:

You can send security operations alongside non-security updates in the same call: one PATCH, one redeployment.

from h2o_mlops.options import KubernetesOptions
from h2o_mlops.types import SecurityAction


target = next(
s for s in dep_multi.securities_options if s.name == "combined-add"
)

dep_multi.update(
security_options=[
SecurityOptions(
action=SecurityAction.EDIT,
uid=target.uid,
security_type=SecurityType.PLAIN_PASSPHRASE,
passphrase=None, # preserve the stored hash
name="combined-add-renamed",
),
],
kubernetes_options=KubernetesOptions(replicas=2),
)

Rotate an API_KEY​

An EDIT on an API_KEY entry silently preserves the existing key. The server does not generate a new key on update and does not raise an error. To rotate:

  1. In a single update() call, ADD a new API_KEY entry and DELETE the old one. Bundling both operations keeps the deployment continuously authenticated through one PATCH.
  2. Read the new plaintext from dep.securities_options immediately after the call returns.
from h2o_mlops.types import SecurityAction


# Find the existing API_KEY entry to retire.
old_key = next(
s for s in dep_api_key.securities_options
if s.security_type == SecurityType.API_KEY
)

dep_api_key.update(
security_options=[
SecurityOptions(
action=SecurityAction.ADD,
security_type=SecurityType.API_KEY,
name="service-key-rotated",
),
SecurityOptions(action=SecurityAction.DELETE, uid=old_key.uid),
],
)

# Read the new plaintext immediately from the response.
new_plaintext = next(
s.api_key for s in dep_api_key.securities_options
if s.name == "service-key-rotated"
)
print(f"New API key (save this now): {new_plaintext!r}")

SecurityOptions field reference​

FieldTypeNotes
security_typeSecurityType | NoneRequired for most operations. May be None only when action == SecurityAction.DELETE.
passphrasestr | NoneRequired for PLAIN_PASSPHRASE and HASHED_PASSPHRASE. Must be None for DISABLED, OIDC_AUTH, and API_KEY (raises ValueError otherwise). On EDIT, None preserves the stored hash; supplying a value re-hashes.
api_keystr | NoneRead-only. Server-populated on the create response, exactly once. Must be None on create and update (raises ValueError otherwise).
api_key_prefixstr | NoneRead-only. Short display prefix returned on every read. Safe to include in audit logs.
namestr | NoneOptional human-readable label. Not used for identity or deduplication.
uidstr | NoneServer-assigned. Leave unset on create. Required (positive-int string) for EDIT and DELETE.
actionSecurityAction | NonePer-entry operation tag (ADD, EDIT, or DELETE) for update calls. Required on every entry in an update list; all entries in a single update must consistently set action.

SecurityAction enum reference​

SecurityActionWire valueConstraints
ADD"add"Leave uid as None; the server assigns one. Provide security_type. For passphrase types, provide a non-empty passphrase.
EDIT"edit"Provide a positive-int uid matching an existing entry. Provide security_type. For passphrase types, passphrase=None preserves the stored hash; a non-None value re-hashes.
DELETE"delete"Provide a positive-int uid matching an existing entry. The server ignores all other fields. security_type may be None.

All entries in a single update must consistently set action. Mixing tagged and untagged entries raises ValueError before any HTTP call.

Validation rules​

The client enforces all rules below before any HTTP call. The deployment stays untouched on any ValueError.

FailureWhyExact message (partial)
Mixing tagged and untagged entriesAll entries in a single update must consistently set action."All SecurityOptions entries must have an action, or none of them must."
ADD with non-None uidThe server assigns the uid; clients must not supply one."SecurityOptions with action=ADD must have uid=None..."
ADD with missing security_typeRequired to know what to insert."SecurityOptions with action=ADD requires security_type to be set."
ADD with passphrase type but empty passphraseA passphrase entry without a passphrase is incomplete."SecurityOptions with action=ADD and security_type=... requires a non-empty passphrase."
EDIT with missing security_typeRequired to know what to write back."SecurityOptions with action=EDIT requires security_type to be set."
EDIT or DELETE with missing or non-integer uidMust be a positive-int string."SecurityOptions with action=... requires uid to be set (positive-integer string)." / "SecurityOptions.uid ... must be a positive integer string for action=..."
Duplicate uid across actions in one callAmbiguous which operation wins."duplicate uid ... across EDIT/DELETE entries; each uid may appear at most once per update."

The following example triggers the mixed-mode ValueError:

from h2o_mlops.types import SecurityAction


# Raises ValueError before any HTTP call:
dep_multi.update(
security_options=[
SecurityOptions(action=SecurityAction.ADD, security_type=SecurityType.OIDC_AUTH),
SecurityOptions(security_type=SecurityType.DISABLED), # untagged -- raises ValueError
],
)

Feedback