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:
2026-03-16 21:09:38 -07:00
parent b0afe3b993
commit 39d9ffb79a
5 changed files with 136 additions and 12 deletions

View File

@@ -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()