diff --git a/clients/go/client.go b/clients/go/client.go index 432ea87..86b414c 100644 --- a/clients/go/client.go +++ b/clients/go/client.go @@ -185,16 +185,28 @@ type Options struct { CACertPath string // Token is an optional pre-existing bearer token. 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. // Security: the bearer token is guarded by a sync.RWMutex; it is never // written to logs or included in error messages in this library. type Client struct { - baseURL string - http *http.Client - mu sync.RWMutex - token string + baseURL string + http *http.Client + serviceName 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} c := &Client{ - baseURL: serverURL, - http: &http.Client{Transport: transport}, - token: opts.Token, + baseURL: serverURL, + http: &http.Client{Transport: transport}, + token: opts.Token, + serviceName: opts.ServiceName, + tags: opts.Tags, } return c, nil } @@ -343,16 +357,28 @@ func (c *Client) GetPublicKey() (*PublicKey, error) { // Login authenticates with username and password. On success the token is // stored in the Client and returned along with the expiry timestamp. // 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) { - req := map[string]string{"username": username, "password": password} + body := map[string]interface{}{ + "username": username, + "password": password, + } 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 { Token string `json:"token"` 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 } c.setToken(resp.Token) diff --git a/clients/python/mcias_client/_client.py b/clients/python/mcias_client/_client.py index 58d6ce8..df593f1 100644 --- a/clients/python/mcias_client/_client.py +++ b/clients/python/mcias_client/_client.py @@ -20,9 +20,13 @@ class Client: ca_cert_path: str | None = None, token: str | None = None, timeout: float = 30.0, + service_name: str | None = None, + tags: list[str] | None = None, ) -> None: self._base_url = server_url.rstrip("/") self.token = token + self._service_name = service_name + self._tags = tags or [] ssl_context: ssl.SSLContext | bool if ca_cert_path is not None: ssl_context = ssl.create_default_context(cafile=ca_cert_path) @@ -115,6 +119,9 @@ class Client: ) -> tuple[str, str]: """POST /v1/auth/login — authenticate and obtain a JWT. 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] = { "username": username, @@ -122,6 +129,10 @@ class Client: } if totp_code is not None: 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) assert data is not None token = str(data["token"]) diff --git a/clients/rust/src/lib.rs b/clients/rust/src/lib.rs index 28dccc5..08f4e3c 100644 --- a/clients/rust/src/lib.rs +++ b/clients/rust/src/lib.rs @@ -227,6 +227,10 @@ struct LoginRequest<'a> { password: &'a str, #[serde(skip_serializing_if = "Option::is_none")] 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, } #[derive(Deserialize)] @@ -268,6 +272,16 @@ pub struct ClientOptions { /// Optional pre-existing bearer token. pub token: Option, + + /// 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, + + /// 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, } // ---- Client ---- @@ -280,6 +294,8 @@ pub struct ClientOptions { pub struct Client { base_url: String, http: reqwest::Client, + service_name: Option, + tags: Vec, /// Bearer token storage. `Arc>` so clones share the token. /// Security: the token is never logged or included in error messages. token: Arc>>, @@ -306,6 +322,8 @@ impl Client { Ok(Self { base_url: base_url.trim_end_matches('/').to_owned(), http, + service_name: opts.service_name, + tags: opts.tags, token: Arc::new(RwLock::new(opts.token)), }) } @@ -336,6 +354,8 @@ impl Client { username, password, totp_code, + service_name: self.service_name.as_deref(), + tags: self.tags.clone(), }; let resp: TokenResponse = self.post("/v1/auth/login", &body).await?; *self.token.write().await = Some(resp.token.clone()); diff --git a/internal/server/server.go b/internal/server/server.go index 4744e4a..680d79a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -436,6 +436,12 @@ type loginRequest struct { Username string `json:"username"` Password string `json:"password"` 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. @@ -546,13 +552,42 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { // Login succeeded: clear any outstanding failure counter. _ = s.db.ClearLoginFailures(acct.ID) - // Determine expiry. - expiry := s.cfg.DefaultExpiry() + // Load roles for expiry decision and policy check. roles, err := s.db.GetRoles(acct.ID) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") 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 { if r == "admin" { expiry = s.cfg.AdminExpiry() diff --git a/openapi.yaml b/openapi.yaml index 44ec464..4bf2391 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -567,6 +567,12 @@ paths: If the account has TOTP enrolled, `totp_code` is 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 tags: [Public] requestBody: @@ -587,6 +593,21 @@ paths: type: string description: Current 6-digit TOTP code. Required if TOTP is enrolled. 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: "200": description: Login successful. Returns JWT and expiry. @@ -607,6 +628,17 @@ paths: value: {error: invalid credentials, code: unauthorized} 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": $ref: "#/components/responses/RateLimited"