Add service-context login policy enforcement
Services send service_name and tags in POST /v1/auth/login. MCIAS evaluates auth:login policy with these as the resource context after credentials are verified, enabling rules like: deny guest/viewer human accounts from env:restricted services deny guest accounts from specific named services - loginRequest: add ServiceName and Tags fields - handleLogin: evaluate policy after credential+TOTP check; policy deny returns 403 (not 401) to distinguish access restriction from bad credentials - Go client: Options.ServiceName/Tags stored on Client, sent automatically in every Login() call - Python client: service_name/tags on __init__, sent in login() - Rust client: ClientOptions.service_name/tags, LoginRequest fields, Client stores and sends them in login() - openapi.yaml: document service_name/tags request fields and 403 response for policy-denied logins - engineering-standards.md: document service_name/tags in [mcias] config section with policy examples Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -185,16 +185,28 @@ type Options struct {
|
|||||||
CACertPath string
|
CACertPath string
|
||||||
// Token is an optional pre-existing bearer token.
|
// Token is an optional pre-existing bearer token.
|
||||||
Token string
|
Token string
|
||||||
|
// ServiceName is the name of this service as registered in MCIAS. It is
|
||||||
|
// sent with every Login call so MCIAS can evaluate service-context policy
|
||||||
|
// rules (e.g. deny guest users from logging into this service).
|
||||||
|
// Populate from [mcias] service_name in the service's config file.
|
||||||
|
ServiceName string
|
||||||
|
// Tags are the service-level tags sent with every Login call. MCIAS
|
||||||
|
// evaluates auth:login policy against these tags, enabling rules such as
|
||||||
|
// "deny guest accounts from services tagged env:restricted".
|
||||||
|
// Populate from [mcias] tags in the service's config file.
|
||||||
|
Tags []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client is a thread-safe MCIAS REST API client.
|
// Client is a thread-safe MCIAS REST API client.
|
||||||
// Security: the bearer token is guarded by a sync.RWMutex; it is never
|
// Security: the bearer token is guarded by a sync.RWMutex; it is never
|
||||||
// written to logs or included in error messages in this library.
|
// written to logs or included in error messages in this library.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
http *http.Client
|
http *http.Client
|
||||||
mu sync.RWMutex
|
serviceName string
|
||||||
token string
|
tags []string
|
||||||
|
mu sync.RWMutex
|
||||||
|
token string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -224,9 +236,11 @@ func New(serverURL string, opts Options) (*Client, error) {
|
|||||||
}
|
}
|
||||||
transport := &http.Transport{TLSClientConfig: tlsCfg}
|
transport := &http.Transport{TLSClientConfig: tlsCfg}
|
||||||
c := &Client{
|
c := &Client{
|
||||||
baseURL: serverURL,
|
baseURL: serverURL,
|
||||||
http: &http.Client{Transport: transport},
|
http: &http.Client{Transport: transport},
|
||||||
token: opts.Token,
|
token: opts.Token,
|
||||||
|
serviceName: opts.ServiceName,
|
||||||
|
tags: opts.Tags,
|
||||||
}
|
}
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
@@ -343,16 +357,28 @@ func (c *Client) GetPublicKey() (*PublicKey, error) {
|
|||||||
// Login authenticates with username and password. On success the token is
|
// Login authenticates with username and password. On success the token is
|
||||||
// stored in the Client and returned along with the expiry timestamp.
|
// stored in the Client and returned along with the expiry timestamp.
|
||||||
// totpCode may be empty for accounts without TOTP.
|
// totpCode may be empty for accounts without TOTP.
|
||||||
|
//
|
||||||
|
// The client's ServiceName and Tags (from Options) are included in the
|
||||||
|
// request so MCIAS can evaluate service-context policy rules.
|
||||||
func (c *Client) Login(username, password, totpCode string) (token, expiresAt string, err error) {
|
func (c *Client) Login(username, password, totpCode string) (token, expiresAt string, err error) {
|
||||||
req := map[string]string{"username": username, "password": password}
|
body := map[string]interface{}{
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
}
|
||||||
if totpCode != "" {
|
if totpCode != "" {
|
||||||
req["totp_code"] = totpCode
|
body["totp_code"] = totpCode
|
||||||
|
}
|
||||||
|
if c.serviceName != "" {
|
||||||
|
body["service_name"] = c.serviceName
|
||||||
|
}
|
||||||
|
if len(c.tags) > 0 {
|
||||||
|
body["tags"] = c.tags
|
||||||
}
|
}
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
ExpiresAt string `json:"expires_at"`
|
ExpiresAt string `json:"expires_at"`
|
||||||
}
|
}
|
||||||
if err := c.do(http.MethodPost, "/v1/auth/login", req, &resp); err != nil {
|
if err := c.do(http.MethodPost, "/v1/auth/login", body, &resp); err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
c.setToken(resp.Token)
|
c.setToken(resp.Token)
|
||||||
|
|||||||
@@ -20,9 +20,13 @@ class Client:
|
|||||||
ca_cert_path: str | None = None,
|
ca_cert_path: str | None = None,
|
||||||
token: str | None = None,
|
token: str | None = None,
|
||||||
timeout: float = 30.0,
|
timeout: float = 30.0,
|
||||||
|
service_name: str | None = None,
|
||||||
|
tags: list[str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._base_url = server_url.rstrip("/")
|
self._base_url = server_url.rstrip("/")
|
||||||
self.token = token
|
self.token = token
|
||||||
|
self._service_name = service_name
|
||||||
|
self._tags = tags or []
|
||||||
ssl_context: ssl.SSLContext | bool
|
ssl_context: ssl.SSLContext | bool
|
||||||
if ca_cert_path is not None:
|
if ca_cert_path is not None:
|
||||||
ssl_context = ssl.create_default_context(cafile=ca_cert_path)
|
ssl_context = ssl.create_default_context(cafile=ca_cert_path)
|
||||||
@@ -115,6 +119,9 @@ class Client:
|
|||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
"""POST /v1/auth/login — authenticate and obtain a JWT.
|
"""POST /v1/auth/login — authenticate and obtain a JWT.
|
||||||
Returns (token, expires_at). Stores the token on self.token.
|
Returns (token, expires_at). Stores the token on self.token.
|
||||||
|
|
||||||
|
The client's service_name and tags are included so MCIAS can evaluate
|
||||||
|
service-context policy rules (e.g. deny guests from restricted services).
|
||||||
"""
|
"""
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"username": username,
|
"username": username,
|
||||||
@@ -122,6 +129,10 @@ class Client:
|
|||||||
}
|
}
|
||||||
if totp_code is not None:
|
if totp_code is not None:
|
||||||
payload["totp_code"] = totp_code
|
payload["totp_code"] = totp_code
|
||||||
|
if self._service_name is not None:
|
||||||
|
payload["service_name"] = self._service_name
|
||||||
|
if self._tags:
|
||||||
|
payload["tags"] = self._tags
|
||||||
data = self._request("POST", "/v1/auth/login", json=payload)
|
data = self._request("POST", "/v1/auth/login", json=payload)
|
||||||
assert data is not None
|
assert data is not None
|
||||||
token = str(data["token"])
|
token = str(data["token"])
|
||||||
|
|||||||
@@ -227,6 +227,10 @@ struct LoginRequest<'a> {
|
|||||||
password: &'a str,
|
password: &'a str,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
totp_code: Option<&'a str>,
|
totp_code: Option<&'a str>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
service_name: Option<&'a str>,
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
|
tags: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -268,6 +272,16 @@ pub struct ClientOptions {
|
|||||||
|
|
||||||
/// Optional pre-existing bearer token.
|
/// Optional pre-existing bearer token.
|
||||||
pub token: Option<String>,
|
pub token: Option<String>,
|
||||||
|
|
||||||
|
/// This service's name as registered in MCIAS. Sent with every login
|
||||||
|
/// request so MCIAS can evaluate service-context policy rules.
|
||||||
|
/// Populate from `[mcias] service_name` in the service config.
|
||||||
|
pub service_name: Option<String>,
|
||||||
|
|
||||||
|
/// Service-level tags sent with every login request. MCIAS evaluates
|
||||||
|
/// `auth:login` policy against these tags.
|
||||||
|
/// Populate from `[mcias] tags` in the service config.
|
||||||
|
pub tags: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Client ----
|
// ---- Client ----
|
||||||
@@ -280,6 +294,8 @@ pub struct ClientOptions {
|
|||||||
pub struct Client {
|
pub struct Client {
|
||||||
base_url: String,
|
base_url: String,
|
||||||
http: reqwest::Client,
|
http: reqwest::Client,
|
||||||
|
service_name: Option<String>,
|
||||||
|
tags: Vec<String>,
|
||||||
/// Bearer token storage. `Arc<RwLock<...>>` so clones share the token.
|
/// Bearer token storage. `Arc<RwLock<...>>` so clones share the token.
|
||||||
/// Security: the token is never logged or included in error messages.
|
/// Security: the token is never logged or included in error messages.
|
||||||
token: Arc<RwLock<Option<String>>>,
|
token: Arc<RwLock<Option<String>>>,
|
||||||
@@ -306,6 +322,8 @@ impl Client {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
base_url: base_url.trim_end_matches('/').to_owned(),
|
base_url: base_url.trim_end_matches('/').to_owned(),
|
||||||
http,
|
http,
|
||||||
|
service_name: opts.service_name,
|
||||||
|
tags: opts.tags,
|
||||||
token: Arc::new(RwLock::new(opts.token)),
|
token: Arc::new(RwLock::new(opts.token)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -336,6 +354,8 @@ impl Client {
|
|||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
totp_code,
|
totp_code,
|
||||||
|
service_name: self.service_name.as_deref(),
|
||||||
|
tags: self.tags.clone(),
|
||||||
};
|
};
|
||||||
let resp: TokenResponse = self.post("/v1/auth/login", &body).await?;
|
let resp: TokenResponse = self.post("/v1/auth/login", &body).await?;
|
||||||
*self.token.write().await = Some(resp.token.clone());
|
*self.token.write().await = Some(resp.token.clone());
|
||||||
|
|||||||
@@ -436,6 +436,12 @@ type loginRequest struct {
|
|||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
TOTPCode string `json:"totp_code,omitempty"`
|
TOTPCode string `json:"totp_code,omitempty"`
|
||||||
|
// ServiceName and Tags identify the calling service. MCIAS evaluates the
|
||||||
|
// auth:login policy with these as the resource context, enabling operators
|
||||||
|
// to restrict which roles/account-types may log into specific services.
|
||||||
|
// Clients populate these from their [mcias] config section.
|
||||||
|
ServiceName string `json:"service_name,omitempty"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// loginResponse is the response body for a successful login.
|
// loginResponse is the response body for a successful login.
|
||||||
@@ -546,13 +552,42 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Login succeeded: clear any outstanding failure counter.
|
// Login succeeded: clear any outstanding failure counter.
|
||||||
_ = s.db.ClearLoginFailures(acct.ID)
|
_ = s.db.ClearLoginFailures(acct.ID)
|
||||||
|
|
||||||
// Determine expiry.
|
// Load roles for expiry decision and policy check.
|
||||||
expiry := s.cfg.DefaultExpiry()
|
|
||||||
roles, err := s.db.GetRoles(acct.ID)
|
roles, err := s.db.GetRoles(acct.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Policy check: evaluate auth:login with the calling service's context.
|
||||||
|
// Operator rules can deny login based on role, account type, service name,
|
||||||
|
// or tags. The built-in default Allow for auth:login is overridden by any
|
||||||
|
// matching Deny rule (deny-wins semantics).
|
||||||
|
//
|
||||||
|
// Security: policy is checked after credential verification so that a
|
||||||
|
// policy-denied login returns 403 (not 401), distinguishing a service
|
||||||
|
// access restriction from a wrong password without leaking user existence.
|
||||||
|
{
|
||||||
|
input := policy.PolicyInput{
|
||||||
|
Subject: acct.UUID,
|
||||||
|
AccountType: string(acct.AccountType),
|
||||||
|
Roles: roles,
|
||||||
|
Action: policy.ActionLogin,
|
||||||
|
Resource: policy.Resource{
|
||||||
|
ServiceName: req.ServiceName,
|
||||||
|
Tags: req.Tags,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if effect, _ := s.polEng.Evaluate(input); effect == policy.Deny {
|
||||||
|
s.writeAudit(r, model.EventLoginFail, &acct.ID, nil,
|
||||||
|
audit.JSON("reason", "policy_deny", "service_name", req.ServiceName))
|
||||||
|
middleware.WriteError(w, http.StatusForbidden, "access denied by policy", "policy_denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine expiry.
|
||||||
|
expiry := s.cfg.DefaultExpiry()
|
||||||
for _, r := range roles {
|
for _, r := range roles {
|
||||||
if r == "admin" {
|
if r == "admin" {
|
||||||
expiry = s.cfg.AdminExpiry()
|
expiry = s.cfg.AdminExpiry()
|
||||||
|
|||||||
32
openapi.yaml
32
openapi.yaml
@@ -567,6 +567,12 @@ paths:
|
|||||||
|
|
||||||
If the account has TOTP enrolled, `totp_code` is required.
|
If the account has TOTP enrolled, `totp_code` is required.
|
||||||
Omitting it returns HTTP 401 with code `totp_required`.
|
Omitting it returns HTTP 401 with code `totp_required`.
|
||||||
|
|
||||||
|
`service_name` and `tags` identify the calling service. MCIAS
|
||||||
|
evaluates `auth:login` policy against these values after credentials
|
||||||
|
are verified. A policy-denied login returns HTTP 403 (not 401) so
|
||||||
|
callers can distinguish a service access restriction from bad credentials.
|
||||||
|
Clients should populate these from their `[mcias]` config section.
|
||||||
operationId: login
|
operationId: login
|
||||||
tags: [Public]
|
tags: [Public]
|
||||||
requestBody:
|
requestBody:
|
||||||
@@ -587,6 +593,21 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
description: Current 6-digit TOTP code. Required if TOTP is enrolled.
|
description: Current 6-digit TOTP code. Required if TOTP is enrolled.
|
||||||
example: "123456"
|
example: "123456"
|
||||||
|
service_name:
|
||||||
|
type: string
|
||||||
|
description: >
|
||||||
|
Name of the calling service. Used by MCIAS to evaluate
|
||||||
|
auth:login policy rules that target specific services.
|
||||||
|
example: metatron
|
||||||
|
tags:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: >
|
||||||
|
Tags describing the calling service (e.g. "env:restricted").
|
||||||
|
MCIAS evaluates auth:login policy rules with required_tags
|
||||||
|
against this list.
|
||||||
|
example: ["env:restricted"]
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Login successful. Returns JWT and expiry.
|
description: Login successful. Returns JWT and expiry.
|
||||||
@@ -607,6 +628,17 @@ paths:
|
|||||||
value: {error: invalid credentials, code: unauthorized}
|
value: {error: invalid credentials, code: unauthorized}
|
||||||
totp_required:
|
totp_required:
|
||||||
value: {error: TOTP code required, code: totp_required}
|
value: {error: TOTP code required, code: totp_required}
|
||||||
|
"403":
|
||||||
|
description: >
|
||||||
|
Login denied by policy. Credentials were valid but an operator
|
||||||
|
policy rule blocks this account from accessing the calling service.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
examples:
|
||||||
|
policy_denied:
|
||||||
|
value: {error: access denied by policy, code: policy_denied}
|
||||||
"429":
|
"429":
|
||||||
$ref: "#/components/responses/RateLimited"
|
$ref: "#/components/responses/RateLimited"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user