21 Commits

Author SHA1 Message Date
44a1b9ad3a Register SSO client templates
The sso_clients page and sso_client_row fragment were missing from the
template registration lists, causing a 500 on GET /sso-clients.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 07:49:45 -07:00
df7773229c Move SSO clients from config to database
- Add sso_clients table (migration 000010) with client_id, redirect_uri,
  tags (JSON), enabled flag, and audit timestamps
- Add SSOClient model struct and audit events
- Implement DB CRUD with 10 unit tests
- Add REST API: GET/POST/PATCH/DELETE /v1/sso/clients (policy-gated)
- Add gRPC SSOClientService with 5 RPCs (admin-only)
- Add mciasctl sso list/create/get/update/delete commands
- Add web UI admin page at /sso-clients with HTMX create/toggle/delete
- Migrate handleSSOAuthorize and handleSSOTokenExchange to use DB
- Remove SSOConfig, SSOClient struct, lookup methods from config
- Simplify: client_id = service_name for policy evaluation

Security:
- SSO client CRUD is admin-only (policy-gated REST, requireAdmin gRPC)
- redirect_uri must use https:// (validated at DB layer)
- Disabled clients are rejected at both authorize and token exchange
- All mutations write audit events

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 23:47:53 -07:00
4430ce38a4 Allow htmx swap styles in CSP
Add 'unsafe-hashes' with the htmx swap indicator style hash to the
style-src CSP directive. Without this, htmx swap transitions are
blocked by CSP, which can prevent HX-Redirect from being processed
on the SSO login flow.

Security:
- Uses 'unsafe-hashes' (not 'unsafe-inline') so only the specific
  htmx style hash is permitted, not arbitrary inline styles

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:43:53 -07:00
4ed2cecec5 Fix SSO redirect failing with htmx login form
The login form uses hx-post, so htmx sends the POST via fetch. A 302
redirect to the cross-origin service callback URL fails silently because
fetch follows the redirect but gets blocked by CORS. Use HX-Redirect
header instead, which tells htmx to perform a full page navigation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:32:44 -07:00
9385c3846d Fix protobuf runtime panic on startup
Regenerated protobuf stubs and bumped google.golang.org/protobuf from
v1.36.10 to v1.36.11 to match protoc-gen-go v1.36.11. The version
mismatch caused a panic in filedesc.unmarshalSeed during init().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:55:15 -07:00
e450ade988 Add SSO authorization code flow (Phase 1)
MCIAS now acts as an SSO provider for downstream services. Services
redirect users to /sso/authorize, MCIAS handles login (password, TOTP,
or passkey), then redirects back with an authorization code that the
service exchanges for a JWT via POST /v1/sso/token.

- Add SSO client registry to config (client_id, redirect_uri,
  service_name, tags) with validation
- Add internal/sso package: authorization code and session stores
  using sync.Map with TTL, single-use LoadAndDelete, cleanup goroutines
- Add GET /sso/authorize endpoint (validates client, creates session,
  redirects to /login?sso=<nonce>)
- Add POST /v1/sso/token endpoint (exchanges code for JWT with policy
  evaluation using client's service_name/tags from config)
- Thread SSO nonce through password→TOTP and WebAuthn login flows
- Update login.html, totp_step.html, and webauthn.js for SSO nonce
  passthrough

Security:
- Authorization codes are 256-bit random, single-use, 60-second TTL
- redirect_uri validated as exact match against registered config
- Policy context comes from MCIAS config, not the calling service
- SSO sessions are server-side only; nonce is the sole client-visible value
- WebAuthn SSO returns redirect URL as JSON (not HTTP redirect) for JS compat

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:21:48 -07:00
5b5e1a7ed6 Use mcdsl/terminal for all password prompts
Replace direct golang.org/x/term calls with mcdsl/terminal.ReadPassword
across mciasctl (6 sites), mciasgrpcctl (1 site), and mciasdb (1 site).
Aligns with the new CLI security standard in engineering-standards.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:40:11 -07:00
e4220b840e flake: install shell completions for both mciasctl and mciasgrpcctl
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:49:10 -07:00
cff7276293 mciasctl: convert from flag to cobra
Adds shell completion support (zsh, bash, fish) via cobra's built-in
completion command. All existing behavior and security measures are
preserved.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:48:24 -07:00
be3bc807b7 mciasgrpcctl: convert from flag to cobra
Adds shell completion support (zsh, bash, fish) via cobra's built-in
completion command. All existing behavior and security measures are
preserved.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:48:02 -07:00
ead32f72f8 Add MCR registry tagging and push target to Makefile
Add MCR variable. Replace local mcias:VERSION tags with MCR registry
URL. Remove :latest tag. Add push target.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:32:21 -07:00
d7d80c0f25 Bump flake.nix version to match latest tag
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:16:34 -07:00
41d01edfb4 Migrate module path from kyle/ to mc/ org
All import paths updated from git.wntrmute.dev/kyle/mcias to
git.wntrmute.dev/mc/mcias to match the Gitea organization.
Includes main module and clients/go submodule.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:03:46 -07:00
9b521f3d99 Add MCP deployment note to runbook
- MCIAS is not managed by MCP due to circular dependency
- Points operators to existing systemd deployment sections

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:09:23 -07:00
115f23a3ea Add Nix flake for mciasctl and mciasgrpcctl
Vendor dependencies and expose control program binaries via
nix build. Uses nixpkgs-unstable for Go 1.26 support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:01:21 -07:00
35e96444aa Include account_type in token validation response
The /v1/token/validate endpoint now returns account_type ("human" or
"system") alongside username and roles. The account lookup was already
happening — this just surfaces the type in the response.

Required by downstream services (MCR, Metacrypt) whose policy engines
match on account type.

Security: no new data exposure — account_type is non-sensitive metadata
already available to any authenticated admin via GET /v1/accounts/{id}.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 17:45:04 -07:00
db7cd73a6e Fix WebAuthn login: username pre-fill and policy check
- webauthn.js: read #username value before calling
  mciasWebAuthnLogin so non-discoverable keys work when
  a username is typed (previously always passed empty string,
  forcing discoverable/resident-key flow only)

- handleWebAuthnLoginFinish: evaluate auth:login policy after
  credential verification, mirroring the gate in handleLogin;
  returns 403 on deny so policy rules apply equally to both
  password and passkey authentication paths

Security: policy is checked post-verification so 403 vs 401
distinguishes a policy restriction from a bad credential without
leaking account existence. No service context is sent (WebAuthn
login carries no service_name/tags), so per-service deny rules
don't fire on passkey login; account-level deny rules do.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 14:04:51 -07:00
39d9ffb79a 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>
2026-03-16 21:11:35 -07:00
b0afe3b993 Align with engineering standards (steps 1-5)
- Rename dist/ -> deploy/ with subdirs examples/, scripts/,
  systemd/ per standard repository layout
- Update .gitignore: gitignore all of dist/ (build output only)
- Makefile: all target is now vet->lint->test->build; add vet,
  proto-lint, devserver targets; CGO_ENABLED=0 for builds
  (modernc.org/sqlite is pure-Go, no C toolchain needed);
  CGO_ENABLED=1 retained for tests (race detector)
- Dockerfile: builder -> golang:1.26-alpine, runtime ->
  alpine:3.21; drop libc6 dep; add /srv/mcias/certs and
  /srv/mcias/backups to image
- deploy/systemd/mcias.service: add RestrictSUIDSGID=true
- deploy/systemd/mcias-backup.service: new oneshot backup unit
- deploy/systemd/mcias-backup.timer: daily 02:00 UTC, 5m jitter
- deploy/scripts/install.sh: install backup units and enable
  timer; create certs/ and backups/ subdirs in /srv/mcias
- buf.yaml: add proto linting config for proto-lint target
- internal/db: add Snapshot and SnapshotDir methods (VACUUM INTO)
- cmd/mciasdb: add snapshot subcommand; no master key required
2026-03-16 20:26:43 -07:00
446b3df52d Fix WebAuthn CSRF; clarify security key UI
- Fix webauthn.js CSRF token: read HMAC header value from
  body hx-headers attribute instead of cookie nonce
- Update profile labels to mention security keys/FIDO2
  alongside passkeys

Security: CSRF double-submit was broken for fetch()-based
WebAuthn requests — JS was sending the cookie nonce as the
header value instead of the HMAC. Fixed by reading the
server-rendered header token from the DOM.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 19:27:44 -07:00
0b37fde155 Add WebAuthn config; Docker single-mount
- Add [webauthn] section to all config examples
- Add active WebAuthn config to run/mcias.conf
- Update Dockerfile to use /srv/mcias single mount
- Add WebAuthn and TOTP sections to RUNBOOK.md
- Fix TOTP QR display (template.URL type)
- Add --force-rm to docker build in Makefile

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 18:57:06 -07:00
2695 changed files with 6823985 additions and 1808 deletions

3
.gitignore vendored
View File

@@ -20,7 +20,8 @@ mcias.toml
*~ *~
go.work go.work
go.work.sum go.work.sum
dist/mcias_*.tar.gz # dist/ is purely build output (tarballs); never commit it
dist/
man/man1/*.gz man/man1/*.gz
# Client library build artifacts # Client library build artifacts

View File

@@ -1 +0,0 @@
CLAUDE.md

View File

@@ -1377,7 +1377,7 @@ Error types exposed by every library:
#### Go (`clients/go/`) #### Go (`clients/go/`)
- Module: `git.wntrmute.dev/kyle/mcias/clients/go` - Module: `git.wntrmute.dev/mc/mcias/clients/go`
- Package: `mciasgoclient` - Package: `mciasgoclient`
- HTTP: `net/http` with custom `*tls.Config` for CA cert - HTTP: `net/http` with custom `*tls.Config` for CA cert
- Token state: guarded by `sync.RWMutex` - Token state: guarded by `sync.RWMutex`

View File

@@ -1,12 +1,16 @@
# Dockerfile — MCIAS multi-stage container image # Dockerfile — MCIAS multi-stage container image
# #
# Stage 1 (builder): Compiles all four MCIAS binaries. # Stage 1 (builder): Compiles all four MCIAS binaries.
# Stage 2 (runtime): Minimal Debian image containing only the binaries. # Stage 2 (runtime): Minimal Alpine image containing only the binaries.
#
# modernc.org/sqlite is a pure-Go, CGo-free SQLite port. CGO_ENABLED=0
# produces fully static binaries with no C library dependencies, which
# deploy cleanly onto a minimal Alpine runtime image.
# #
# The final image: # The final image:
# - Runs as non-root uid 10001 (mcias) # - Runs as non-root uid 10001 (mcias)
# - Exposes port 8443 (REST/TLS) and 9443 (gRPC/TLS) # - Exposes port 8443 (REST/TLS) and 9443 (gRPC/TLS)
# - Declares VOLUME /data for the SQLite database # - Declares VOLUME /srv/mcias for config, TLS, and database
# - Does NOT contain the Go toolchain, source code, or build cache # - Does NOT contain the Go toolchain, source code, or build cache
# #
# Build: # Build:
@@ -15,8 +19,7 @@
# Run: # Run:
# docker run -d \ # docker run -d \
# --name mcias \ # --name mcias \
# -v /path/to/config:/etc/mcias:ro \ # -v /srv/mcias:/srv/mcias \
# -v mcias-data:/data \
# -e MCIAS_MASTER_PASSPHRASE=your-passphrase \ # -e MCIAS_MASTER_PASSPHRASE=your-passphrase \
# -p 8443:8443 \ # -p 8443:8443 \
# -p 9443:9443 \ # -p 9443:9443 \
@@ -25,7 +28,7 @@
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Stage 1 — builder # Stage 1 — builder
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
FROM golang:1.26-bookworm AS builder FROM golang:1.26-alpine AS builder
WORKDIR /build WORKDIR /build
@@ -36,35 +39,29 @@ RUN go mod download
# Copy source. # Copy source.
COPY . . COPY . .
# CGO_ENABLED=1 is required by modernc.org/sqlite (pure-Go CGo-free SQLite). # CGO_ENABLED=0: modernc.org/sqlite is pure Go; no C toolchain required.
# -trimpath removes local file system paths from the binary. # -trimpath removes local file system paths from the binary.
# -ldflags="-s -w" strips the DWARF debug info and symbol table to reduce # -ldflags="-s -w" strips the DWARF debug info and symbol table to reduce
# image size. # image size.
RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /out/mciassrv ./cmd/mciassrv && \ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mciassrv ./cmd/mciassrv && \
CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /out/mciasctl ./cmd/mciasctl && \ CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mciasctl ./cmd/mciasctl && \
CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /out/mciasdb ./cmd/mciasdb && \ CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mciasdb ./cmd/mciasdb && \
CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /out/mciasgrpcctl ./cmd/mciasgrpcctl CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mciasgrpcctl ./cmd/mciasgrpcctl
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Stage 2 — runtime # Stage 2 — runtime
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
FROM debian:bookworm-slim FROM alpine:3.21
# Install runtime dependencies.
# ca-certificates: required to validate external TLS certificates. # ca-certificates: required to validate external TLS certificates.
# libc6: required by CGo-compiled binaries (sqlite). RUN apk add --no-cache ca-certificates
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates \
libc6 && \
rm -rf /var/lib/apt/lists/*
# Create a non-root user for the service. # Create a non-root user for the service.
# uid/gid 10001 is chosen to be well above the range typically assigned to # uid/gid 10001 is chosen to be well above the range typically assigned to
# system users (1999) and human users (1000+), reducing the chance of # system users (1999) and human users (1000+), reducing the chance of
# collision with existing uids on the host when using host networking. # collision with existing uids on the host when using host networking.
RUN groupadd --gid 10001 mcias && \ RUN addgroup -g 10001 mcias && \
useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin mcias adduser -u 10001 -G mcias -H -s /sbin/nologin -D mcias
# Copy compiled binaries from the builder stage. # Copy compiled binaries from the builder stage.
COPY --from=builder /out/mciassrv /usr/local/bin/mciassrv COPY --from=builder /out/mciassrv /usr/local/bin/mciassrv
@@ -72,17 +69,15 @@ COPY --from=builder /out/mciasctl /usr/local/bin/mciasctl
COPY --from=builder /out/mciasdb /usr/local/bin/mciasdb COPY --from=builder /out/mciasdb /usr/local/bin/mciasdb
COPY --from=builder /out/mciasgrpcctl /usr/local/bin/mciasgrpcctl COPY --from=builder /out/mciasgrpcctl /usr/local/bin/mciasgrpcctl
# Create the config and data directories. # Create the data directory.
# /etc/mcias is mounted read-only by the operator with the config file, # /srv/mcias is mounted from the host with config, TLS certs, and database.
# TLS cert, and TLS key. RUN mkdir -p /srv/mcias/certs /srv/mcias/backups && \
# /data is the SQLite database mount point. chown -R mcias:mcias /srv/mcias && \
RUN mkdir -p /etc/mcias /data && \ chmod 0750 /srv/mcias
chown mcias:mcias /data && \
chmod 0750 /data
# Declare /data as a volume so the operator must explicitly mount it. # Declare /srv/mcias as a volume so the operator must explicitly mount it.
# The SQLite database must persist across container restarts. # Contains the config file, TLS cert/key, and SQLite database.
VOLUME /data VOLUME /srv/mcias
# REST/TLS port and gRPC/TLS port. These are documentation only; the actual # REST/TLS port and gRPC/TLS port. These are documentation only; the actual
# ports are set in the config file. Override by mounting a different config. # ports are set in the config file. Override by mounting a different config.
@@ -93,7 +88,8 @@ EXPOSE 9443
USER mcias USER mcias
# Default entry point and config path. # Default entry point and config path.
# The operator mounts /etc/mcias/mcias.conf from the host or a volume. # The operator mounts /srv/mcias from the host containing mcias.toml,
# See dist/mcias.conf.docker.example for a suitable template. # TLS cert/key, and the SQLite database.
# See deploy/examples/mcias.conf.docker.example for a suitable template.
ENTRYPOINT ["mciassrv"] ENTRYPOINT ["mciassrv"]
CMD ["-config", "/etc/mcias/mcias.conf"] CMD ["-config", "/srv/mcias/mcias.toml"]

View File

@@ -381,7 +381,7 @@ expose the same API surface:
| Language | Location | Install | | Language | Location | Install |
|----------|----------|---------| |----------|----------|---------|
| Go | `clients/go/` | `go get git.wntrmute.dev/kyle/mcias/clients/go` | | Go | `clients/go/` | `go get git.wntrmute.dev/mc/mcias/clients/go` |
| Python | `clients/python/` | `pip install ./clients/python` | | Python | `clients/python/` | `pip install ./clients/python` |
| Rust | `clients/rust/` | `cargo add mcias-client` | | Rust | `clients/rust/` | `cargo add mcias-client` |
| Common Lisp | `clients/lisp/` | ASDF `mcias-client` | | Common Lisp | `clients/lisp/` | ASDF `mcias-client` |
@@ -389,7 +389,7 @@ expose the same API surface:
### Go ### Go
```go ```go
import mcias "git.wntrmute.dev/kyle/mcias/clients/go" import mcias "git.wntrmute.dev/mc/mcias/clients/go"
c, err := mcias.New("https://auth.example.com:8443", "/etc/mcias/server.crt", "") c, err := mcias.New("https://auth.example.com:8443", "/etc/mcias/server.crt", "")
if err != nil { ... } if err != nil { ... }

View File

@@ -3,10 +3,14 @@
# Usage: # Usage:
# make build — compile all binaries to bin/ # make build — compile all binaries to bin/
# make test — run tests with race detector # make test — run tests with race detector
# make vet — run go vet
# make lint — run golangci-lint # make lint — run golangci-lint
# make all — vet → lint → test → build (CI pipeline)
# make generate — regenerate protobuf stubs (requires protoc) # make generate — regenerate protobuf stubs (requires protoc)
# make proto-lint — lint proto files with buf
# make man — build compressed man pages # make man — build compressed man pages
# make install — run dist/install.sh (requires root) # make install — run deploy/scripts/install.sh (requires root)
# make devserver — build and run mciassrv against run/ config
# make clean — remove bin/ and generated artifacts # make clean — remove bin/ and generated artifacts
# make dist — build release tarballs for linux/amd64 and linux/arm64 # make dist — build release tarballs for linux/amd64 and linux/arm64
# make docker — build Docker image tagged mcias:$(VERSION) and mcias:latest # make docker — build Docker image tagged mcias:$(VERSION) and mcias:latest
@@ -15,7 +19,8 @@
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Variables # Variables
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
MODULE := git.wntrmute.dev/kyle/mcias MODULE := git.wntrmute.dev/mc/mcias
MCR := mcr.svc.mcp.metacircular.net:8443
BINARIES := mciassrv mciasctl mciasdb mciasgrpcctl BINARIES := mciassrv mciasctl mciasdb mciasgrpcctl
BIN_DIR := bin BIN_DIR := bin
MAN_DIR := man/man1 MAN_DIR := man/man1
@@ -27,20 +32,25 @@ MAN_PAGES := $(MAN_DIR)/mciassrv.1 $(MAN_DIR)/mciasctl.1 \
VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev) VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev)
# Build flags: trim paths from binaries and strip DWARF/symbol table. # Build flags: trim paths from binaries and strip DWARF/symbol table.
# CGO_ENABLED=1 is required for modernc.org/sqlite. # modernc.org/sqlite is pure-Go and does not require CGo; CGO_ENABLED=0
# produces statically linked binaries that deploy cleanly to Alpine containers.
GO := go GO := go
GOFLAGS := -trimpath GOFLAGS := -trimpath
LDFLAGS := -s -w -X main.version=$(VERSION) LDFLAGS := -s -w -X main.version=$(VERSION)
CGO := CGO_ENABLED=1 CGO := CGO_ENABLED=0
# The race detector requires CGo on some platforms, so tests continue to use
# CGO_ENABLED=1 while production builds are CGO_ENABLED=0.
CGO_TEST := CGO_ENABLED=1
# Platforms for cross-compiled dist tarballs. # Platforms for cross-compiled dist tarballs.
DIST_PLATFORMS := linux/amd64 linux/arm64 DIST_PLATFORMS := linux/amd64 linux/arm64
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Default target # Default target — CI pipeline: vet → lint → test → build
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
.PHONY: all .PHONY: all
all: build all: vet lint test build
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# build — compile all binaries to bin/ # build — compile all binaries to bin/
@@ -59,7 +69,14 @@ build:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
.PHONY: test .PHONY: test
test: test:
$(CGO) $(GO) test -race ./... $(CGO_TEST) $(GO) test -race ./...
# ---------------------------------------------------------------------------
# vet — static analysis via go vet
# ---------------------------------------------------------------------------
.PHONY: vet
vet:
$(GO) vet ./...
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# lint — run golangci-lint # lint — run golangci-lint
@@ -68,6 +85,15 @@ test:
lint: lint:
golangci-lint run ./... golangci-lint run ./...
# ---------------------------------------------------------------------------
# proto-lint — lint and check for breaking changes in proto definitions
# Requires: buf (https://buf.build/docs/installation)
# ---------------------------------------------------------------------------
.PHONY: proto-lint
proto-lint:
buf lint
buf breaking --against '.git#branch=master,subdir=proto'
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# generate — regenerate protobuf stubs from proto/ definitions # generate — regenerate protobuf stubs from proto/ definitions
# Requires: protoc, protoc-gen-go, protoc-gen-go-grpc # Requires: protoc, protoc-gen-go, protoc-gen-go-grpc
@@ -76,6 +102,13 @@ lint:
generate: generate:
$(GO) generate ./... $(GO) generate ./...
# ---------------------------------------------------------------------------
# devserver — build and run mciassrv against the local run/ config
# ---------------------------------------------------------------------------
.PHONY: devserver
devserver: build
$(BIN_DIR)/mciassrv -config run/mcias.conf
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# man — build compressed man pages # man — build compressed man pages
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -90,7 +123,7 @@ man: $(patsubst %.1,%.1.gz,$(MAN_PAGES))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
.PHONY: install .PHONY: install
install: build install: build
sh dist/install.sh sh deploy/scripts/install.sh
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# clean — remove build artifacts # clean — remove build artifacts
@@ -98,6 +131,7 @@ install: build
.PHONY: clean .PHONY: clean
clean: clean:
rm -rf $(BIN_DIR) rm -rf $(BIN_DIR)
rm -rf dist/
rm -f $(patsubst %.1,%.1.gz,$(MAN_PAGES)) rm -f $(patsubst %.1,%.1.gz,$(MAN_PAGES))
-docker rmi mcias:$(VERSION) mcias:latest 2>/dev/null || true -docker rmi mcias:$(VERSION) mcias:latest 2>/dev/null || true
@@ -106,7 +140,7 @@ clean:
# #
# Output files: dist/mcias_<version>_<os>_<arch>.tar.gz # Output files: dist/mcias_<version>_<os>_<arch>.tar.gz
# Each tarball contains: mciassrv, mciasctl, mciasdb, mciasgrpcctl, # Each tarball contains: mciassrv, mciasctl, mciasdb, mciasgrpcctl,
# man pages, and dist/ files. # man pages, and deploy/ files.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
.PHONY: dist .PHONY: dist
dist: man dist: man
@@ -117,14 +151,12 @@ dist: man
echo " DIST $$platform -> $$outdir.tar.gz"; \ echo " DIST $$platform -> $$outdir.tar.gz"; \
mkdir -p $$outdir/bin; \ mkdir -p $$outdir/bin; \
for bin in $(BINARIES); do \ for bin in $(BINARIES); do \
CGO_ENABLED=1 GOOS=$$os GOARCH=$$arch $(GO) build \ CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch $(GO) build \
$(GOFLAGS) -ldflags "$(LDFLAGS)" \ $(GOFLAGS) -ldflags "$(LDFLAGS)" \
-o $$outdir/bin/$$bin ./cmd/$$bin; \ -o $$outdir/bin/$$bin ./cmd/$$bin; \
done; \ done; \
cp -r man $$outdir/; \ cp -r man $$outdir/; \
cp dist/mcias.conf.example dist/mcias-dev.conf.example \ cp -r deploy $$outdir/; \
dist/mcias.env.example dist/mcias.service \
dist/install.sh $$outdir/; \
tar -czf $$outdir.tar.gz -C dist mcias_$$(echo $(VERSION) | tr -d 'v')_$${os}_$${arch}; \ tar -czf $$outdir.tar.gz -C dist mcias_$$(echo $(VERSION) | tr -d 'v')_$${os}_$${arch}; \
rm -rf $$outdir; \ rm -rf $$outdir; \
done done
@@ -132,9 +164,12 @@ dist: man
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# docker — build the Docker image # docker — build the Docker image
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
.PHONY: docker .PHONY: docker push
docker: docker:
docker build -t mcias:$(VERSION) -t mcias:latest . docker build --force-rm -t $(MCR)/mcias:$(VERSION) .
push: docker
docker push $(MCR)/mcias:$(VERSION)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# docker-clean — remove local mcias Docker images # docker-clean — remove local mcias Docker images
@@ -154,13 +189,17 @@ install-local: build
.PHONY: help .PHONY: help
help: help:
@echo "Available targets:" @echo "Available targets:"
@echo " build Compile all binaries to bin/" @echo " all vet → lint → test → build (CI pipeline)"
@echo " test Run tests with race detector" @echo " build Compile all binaries to bin/"
@echo " lint Run golangci-lint" @echo " test Run tests with race detector"
@echo " generate Regenerate protobuf stubs" @echo " vet Run go vet"
@echo " man Build compressed man pages" @echo " lint Run golangci-lint"
@echo " install Install to /usr/local/bin (requires root)" @echo " proto-lint Lint proto files with buf"
@echo " clean Remove build artifacts" @echo " generate Regenerate protobuf stubs"
@echo " dist Build release tarballs for Linux amd64/arm64" @echo " devserver Build and run mciassrv against run/ config"
@echo " docker Build Docker image mcias:$(VERSION) and mcias:latest" @echo " man Build compressed man pages"
@echo " install Install to /usr/local/bin (requires root)"
@echo " clean Remove build artifacts"
@echo " dist Build release tarballs for Linux amd64/arm64"
@echo " docker Build Docker image mcias:$(VERSION) and mcias:latest"
@echo " docker-clean Remove local mcias Docker images" @echo " docker-clean Remove local mcias Docker images"

View File

@@ -21,7 +21,7 @@ features implemented beyond the original plan scope.
### Step 0.1: Go module and dependency setup ### Step 0.1: Go module and dependency setup
**Acceptance criteria:** **Acceptance criteria:**
- `go.mod` exists with module path `git.wntrmute.dev/kyle/mcias` - `go.mod` exists with module path `git.wntrmute.dev/mc/mcias`
- Required dependencies declared: `modernc.org/sqlite` (CGo-free SQLite), - Required dependencies declared: `modernc.org/sqlite` (CGo-free SQLite),
`golang.org/x/crypto` (Argon2, Ed25519 helpers), `github.com/golang-jwt/jwt/v5`, `golang.org/x/crypto` (Argon2, Ed25519 helpers), `github.com/golang-jwt/jwt/v5`,
`github.com/pelletier/go-toml/v2`, `github.com/google/uuid`, `github.com/pelletier/go-toml/v2`, `github.com/google/uuid`,
@@ -543,7 +543,7 @@ implementation notes.
### Step 9.2: Go client library ### Step 9.2: Go client library
**Acceptance criteria:** **Acceptance criteria:**
- `clients/go/` — Go module `git.wntrmute.dev/kyle/mcias/clients/go` - `clients/go/` — Go module `git.wntrmute.dev/mc/mcias/clients/go`
- Package `mciasgoclient` exposes the canonical API surface from Step 9.1 - Package `mciasgoclient` exposes the canonical API surface from Step 9.1
- Uses `net/http` with `crypto/tls`; custom CA cert supported via `x509.CertPool` - Uses `net/http` with `crypto/tls`; custom CA cert supported via `x509.CertPool`
- Token stored in-memory; `Client.Token()` accessor returns current token - Token stored in-memory; `Client.Token()` accessor returns current token

View File

@@ -17,7 +17,7 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for the technical design and
**Prerequisites:** Go 1.26+, a C compiler (required by modernc.org/sqlite). **Prerequisites:** Go 1.26+, a C compiler (required by modernc.org/sqlite).
```sh ```sh
git clone https://git.wntrmute.dev/kyle/mcias git clone https://git.wntrmute.dev/mc/mcias
cd mcias cd mcias
make build # produces bin/mciassrv, other binaries make build # produces bin/mciassrv, other binaries
sudo make install sudo make install

View File

@@ -322,6 +322,83 @@ mciasdb $CONF audit query --json
--- ---
## WebAuthn / Passkey Configuration
WebAuthn enables passwordless passkey login and hardware security key 2FA.
It is **disabled by default** — to enable it, add a `[webauthn]` section to
`mcias.toml` with the relying party ID and origin.
### Enable WebAuthn
Add to `/srv/mcias/mcias.toml`:
```toml
[webauthn]
rp_id = "auth.example.com"
rp_origin = "https://auth.example.com"
display_name = "MCIAS"
```
- **`rp_id`** — The domain name (no scheme or port). Must match the domain
users see in their browser address bar.
- **`rp_origin`** — The full HTTPS origin. Include the port if non-standard
(e.g., `https://localhost:8443` for development).
- **`display_name`** — Shown to users during browser passkey prompts. Defaults
to "MCIAS" if omitted.
Restart the server after changing the config:
```sh
systemctl restart mcias
```
Once enabled, the **Passkeys** section appears on the user's Profile page
(self-service enrollment) and on the admin Account Detail page (credential
management).
### Passkey enrollment
Passkey enrollment is self-service only. Users add passkeys from their
**Profile → Passkeys** section. Admins can view and remove passkeys from
the Account Detail page but cannot enroll on behalf of users (passkey
registration requires the authenticator device to be present).
### Disable WebAuthn
Remove or comment out the `[webauthn]` section and restart. Existing
credentials remain in the database but are unused. Passkey UI sections
will be hidden.
### Remove all passkeys for an account (break-glass)
```sh
mciasdb --config /srv/mcias/mcias.toml account reset-webauthn --id <UUID>
```
---
## TOTP Two-Factor Authentication
TOTP enrollment is self-service via the **Profile → Two-Factor Authentication**
section. Users enter their current password to begin enrollment, scan the QR
code with an authenticator app, and confirm with a 6-digit code.
### Admin: Remove TOTP for an account
From the web UI: navigate to the account's detail page and click **Remove**
next to the TOTP status.
From the CLI:
```sh
mciasdb --config /srv/mcias/mcias.toml account reset-totp --id <UUID>
```
This clears the TOTP secret and disables the 2FA requirement. The user can
re-enroll from their Profile page.
---
## Master Key Rotation ## Master Key Rotation
> This operation is not yet automated. Until a rotation command is > This operation is not yet automated. Until a rotation command is
@@ -384,6 +461,19 @@ See `dist/mcias.conf.docker.example` for the full annotated Docker config.
--- ---
## MCP Deployment
MCIAS is **not** managed by MCP and does not run on rift. Because MCIAS is the
authentication root for the entire platform — including MCP itself — running it
under MCP would create a circular dependency. Instead, MCIAS runs as a systemd
service on a separate VPS (`svc.metacircular.net`).
All deployment, upgrades, and operational tasks use systemd directly on the VPS.
See the [Installation](#installation), [Routine Operations](#routine-operations),
and [Upgrading](#upgrading) sections above for the relevant procedures.
---
## Troubleshooting ## Troubleshooting
### Server fails to start: "open database" ### Server fails to start: "open database"

14
buf.yaml Normal file
View File

@@ -0,0 +1,14 @@
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
except:
# PACKAGE_VERSION_SUFFIX requires package names to end in a version (e.g.
# mcias.v1). The current protos use mcias.v1 already so this is fine, but
# keeping the exception documents the intent explicitly.
- PACKAGE_VERSION_SUFFIX
breaking:
use:
- FILE

View File

@@ -29,7 +29,7 @@ set_pg_creds(account_id, host, port, database, username, password) → void
| `MciasConflictError` | 409 | Conflict (e.g. duplicate username) | | `MciasConflictError` | 409 | Conflict (e.g. duplicate username) |
| `MciasServerError` | 5xx | Unexpected server error | | `MciasServerError` | 5xx | Unexpected server error |
`testdata/` contains canonical JSON response fixtures shared across language tests. `testdata/` contains canonical JSON response fixtures shared across language tests.
- `go/` — Go module `git.wntrmute.dev/kyle/mcias/clients/go` - `go/` — Go module `git.wntrmute.dev/mc/mcias/clients/go`
- `rust/` — Rust crate `mcias-client` - `rust/` — Rust crate `mcias-client`
- `lisp/` — ASDF system `mcias-client` - `lisp/` — ASDF system `mcias-client`
- `python/` — Python package `mcias_client` - `python/` — Python package `mcias_client`

View File

@@ -9,13 +9,13 @@ Go client library for the [MCIAS](../../README.md) identity and access managemen
## Installation ## Installation
```sh ```sh
go get git.wntrmute.dev/kyle/mcias/clients/go go get git.wntrmute.dev/mc/mcias/clients/go
``` ```
## Quick Start ## Quick Start
```go ```go
import "git.wntrmute.dev/kyle/mcias/clients/go/mcias" import "git.wntrmute.dev/mc/mcias/clients/go/mcias"
// Connect to the MCIAS server. // Connect to the MCIAS server.
client, err := mcias.New("https://auth.example.com", mcias.Options{}) client, err := mcias.New("https://auth.example.com", mcias.Options{})

View File

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

View File

@@ -11,7 +11,7 @@ import (
"strings" "strings"
"testing" "testing"
mcias "git.wntrmute.dev/kyle/mcias/clients/go" mcias "git.wntrmute.dev/mc/mcias/clients/go"
) )
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -1,3 +1,3 @@
module git.wntrmute.dev/kyle/mcias/clients/go module git.wntrmute.dev/mc/mcias/clients/go
go 1.21 go 1.21

View File

View File

@@ -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"])

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -6,9 +6,10 @@ import (
"os" "os"
"strings" "strings"
"git.wntrmute.dev/kyle/mcias/internal/auth" "git.wntrmute.dev/mc/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/mc/mcias/internal/model"
"golang.org/x/term"
"git.wntrmute.dev/mc/mcdsl/terminal"
) )
func (t *tool) runAccount(args []string) { func (t *tool) runAccount(args []string) {
@@ -233,20 +234,14 @@ func (t *tool) accountResetTOTP(args []string) {
// readPassword reads a password from the terminal without echo. // readPassword reads a password from the terminal without echo.
// Falls back to a regular line read if stdin is not a terminal (e.g. in tests). // Falls back to a regular line read if stdin is not a terminal (e.g. in tests).
func readPassword(prompt string) (string, error) { func readPassword(prompt string) (string, error) {
fmt.Fprint(os.Stderr, prompt) pw, err := terminal.ReadPassword(prompt)
fd := int(os.Stdin.Fd()) //nolint:gosec // G115: file descriptors are non-negative and fit in int on all supported platforms if err == nil {
if term.IsTerminal(fd) { return pw, nil
pw, err := term.ReadPassword(fd)
fmt.Fprintln(os.Stderr) // newline after hidden input
if err != nil {
return "", fmt.Errorf("read password from terminal: %w", err)
}
return string(pw), nil
} }
// Not a terminal: read a plain line (for piped input in tests). // Fallback for piped input (e.g. tests).
fmt.Fprint(os.Stderr, prompt)
var line string var line string
_, err := fmt.Fscanln(os.Stdin, &line) if _, err := fmt.Fscanln(os.Stdin, &line); err != nil {
if err != nil {
return "", fmt.Errorf("read password: %w", err) return "", fmt.Errorf("read password: %w", err)
} }
return line, nil return line, nil

View File

@@ -7,8 +7,8 @@ import (
"os" "os"
"time" "time"
"git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/mc/mcias/internal/model"
) )
func (t *tool) runAudit(args []string) { func (t *tool) runAudit(args []string) {

View File

@@ -40,7 +40,7 @@
// pgcreds get --id UUID // pgcreds get --id UUID
// pgcreds set --id UUID --host H --port P --db D --user U // pgcreds set --id UUID --host H --port P --db D --user U
// //
// rekey // snapshot [--retain-days N]
package main package main
import ( import (
@@ -49,9 +49,9 @@ import (
"fmt" "fmt"
"os" "os"
"git.wntrmute.dev/kyle/mcias/internal/config" "git.wntrmute.dev/mc/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/crypto" "git.wntrmute.dev/mc/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/mc/mcias/internal/db"
) )
func main() { func main() {
@@ -68,6 +68,14 @@ func main() {
command := args[0] command := args[0]
subArgs := args[1:] subArgs := args[1:]
// snapshot loads only the config (no master key needed — VACUUM INTO does
// not access encrypted columns) and must be handled before openDB, which
// requires the master key passphrase env var.
if command == "snapshot" {
runSnapshot(*configPath, subArgs)
return
}
// schema subcommands manage migrations themselves and must not trigger // schema subcommands manage migrations themselves and must not trigger
// auto-migration on open (a dirty database would prevent the tool from // auto-migration on open (a dirty database would prevent the tool from
// opening at all, blocking recovery operations like "schema force"). // opening at all, blocking recovery operations like "schema force").
@@ -273,6 +281,11 @@ Commands:
rekey Re-encrypt all secrets under a new master passphrase rekey Re-encrypt all secrets under a new master passphrase
(prompts interactively; requires server to be stopped) (prompts interactively; requires server to be stopped)
snapshot Write a timestamped VACUUM INTO backup to
<db-dir>/backups/; prune backups older than
--retain-days days (default 30, 0 = keep all).
Does not require the master key passphrase.
NOTE: mciasdb bypasses the mciassrv API and operates directly on the SQLite NOTE: mciasdb bypasses the mciassrv API and operates directly on the SQLite
file. Use it only when the server is unavailable or for break-glass recovery. file. Use it only when the server is unavailable or for break-glass recovery.
All write operations are recorded in the audit log. All write operations are recorded in the audit log.

View File

@@ -9,9 +9,9 @@ import (
"testing" "testing"
"time" "time"
"git.wntrmute.dev/kyle/mcias/internal/crypto" "git.wntrmute.dev/mc/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/mc/mcias/internal/model"
) )
// newTestTool creates a tool backed by an in-memory SQLite database with a // newTestTool creates a tool backed by an in-memory SQLite database with a

View File

@@ -6,8 +6,8 @@ import (
"fmt" "fmt"
"os" "os"
"git.wntrmute.dev/kyle/mcias/internal/crypto" "git.wntrmute.dev/mc/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/mc/mcias/internal/db"
) )
func (t *tool) runPGCreds(args []string) { func (t *tool) runPGCreds(args []string) {

View File

@@ -4,8 +4,8 @@ import (
"fmt" "fmt"
"os" "os"
"git.wntrmute.dev/kyle/mcias/internal/crypto" "git.wntrmute.dev/mc/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/mc/mcias/internal/db"
) )
// runRekey re-encrypts all secrets under a new passphrase-derived master key. // runRekey re-encrypts all secrets under a new passphrase-derived master key.

View File

@@ -4,7 +4,7 @@ import (
"flag" "flag"
"fmt" "fmt"
"git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/mc/mcias/internal/db"
) )
func (t *tool) runSchema(args []string) { func (t *tool) runSchema(args []string) {

44
cmd/mciasdb/snapshot.go Normal file
View File

@@ -0,0 +1,44 @@
package main
import (
"flag"
"fmt"
"path/filepath"
"git.wntrmute.dev/mc/mcias/internal/config"
"git.wntrmute.dev/mc/mcias/internal/db"
)
// runSnapshot handles the "snapshot" command.
//
// It opens the database read-only (no master key derivation needed — VACUUM
// INTO does not access encrypted columns) and writes a timestamped backup to
// /srv/mcias/backups/ (or the directory adjacent to the configured DB path).
// Backups older than --retain-days are pruned.
func runSnapshot(configPath string, args []string) {
fs := flag.NewFlagSet("snapshot", flag.ExitOnError)
retainDays := fs.Int("retain-days", 30, "prune backups older than this many days (0 = keep all)")
if err := fs.Parse(args); err != nil {
fatalf("snapshot: %v", err)
}
cfg, err := config.Load(configPath)
if err != nil {
fatalf("snapshot: load config: %v", err)
}
database, err := db.Open(cfg.Database.Path)
if err != nil {
fatalf("snapshot: open database: %v", err)
}
defer func() { _ = database.Close() }()
// Place backups in a "backups" directory adjacent to the database file.
backupDir := filepath.Join(filepath.Dir(cfg.Database.Path), "backups")
dest, err := database.SnapshotDir(backupDir, *retainDays)
if err != nil {
fatalf("snapshot: %v", err)
}
fmt.Printf("snapshot written: %s\n", dest)
}

File diff suppressed because it is too large Load Diff

View File

@@ -31,12 +31,12 @@ import (
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials"
"git.wntrmute.dev/kyle/mcias/internal/config" "git.wntrmute.dev/mc/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/crypto" "git.wntrmute.dev/mc/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/grpcserver" "git.wntrmute.dev/mc/mcias/internal/grpcserver"
"git.wntrmute.dev/kyle/mcias/internal/server" "git.wntrmute.dev/mc/mcias/internal/server"
"git.wntrmute.dev/kyle/mcias/internal/vault" "git.wntrmute.dev/mc/mcias/internal/vault"
) )
func main() { func main() {

View File

@@ -41,3 +41,10 @@ threads = 4
[master_key] [master_key]
passphrase_env = "MCIAS_MASTER_PASSPHRASE" passphrase_env = "MCIAS_MASTER_PASSPHRASE"
# WebAuthn — passkey authentication for local development.
# rp_origin includes the non-standard port since we're not behind a proxy.
[webauthn]
rp_id = "localhost"
rp_origin = "https://localhost:8443"
display_name = "MCIAS (dev)"

View File

@@ -48,3 +48,14 @@ threads = 4
# Set it with: docker run -e MCIAS_MASTER_PASSPHRASE=your-passphrase ... # Set it with: docker run -e MCIAS_MASTER_PASSPHRASE=your-passphrase ...
# or with a Docker secret / Kubernetes secret. # or with a Docker secret / Kubernetes secret.
passphrase_env = "MCIAS_MASTER_PASSPHRASE" passphrase_env = "MCIAS_MASTER_PASSPHRASE"
# ---------------------------------------------------------------------------
# [webauthn] — FIDO2/WebAuthn passkey authentication (OPTIONAL)
# ---------------------------------------------------------------------------
# Uncomment to enable passwordless passkey login. Set rp_id to your domain
# and rp_origin to the full HTTPS origin users access in their browser.
#
# [webauthn]
# rp_id = "auth.example.com"
# rp_origin = "https://auth.example.com"
# display_name = "MCIAS"

View File

@@ -123,3 +123,24 @@ passphrase_env = "MCIAS_MASTER_PASSPHRASE"
# #
# Uncomment and comment out passphrase_env to switch modes. # Uncomment and comment out passphrase_env to switch modes.
# keyfile = "/srv/mcias/master.key" # keyfile = "/srv/mcias/master.key"
# ---------------------------------------------------------------------------
# [webauthn] — FIDO2/WebAuthn passkey authentication (OPTIONAL)
# ---------------------------------------------------------------------------
# Enables passwordless passkey login and hardware security key 2FA.
# If this section is omitted or rp_id/rp_origin are empty, WebAuthn is
# disabled and passkey options will not appear in the UI.
#
# [webauthn]
#
# REQUIRED (if enabling). The Relying Party ID — typically the domain name
# (without port or scheme). Must match the domain users see in their browser.
# rp_id = "auth.example.com"
#
# REQUIRED (if enabling). The Relying Party Origin — the full origin URL
# including scheme. Must be HTTPS. Include the port if non-standard (not 443).
# rp_origin = "https://auth.example.com"
#
# OPTIONAL. Display name shown to users during passkey registration prompts.
# Default: "MCIAS".
# display_name = "MCIAS"

View File

@@ -1,13 +1,13 @@
#!/bin/sh #!/bin/sh
# install.sh — MCIAS first-time and upgrade installer # install.sh — MCIAS first-time and upgrade installer
# #
# Usage: sh dist/install.sh # Usage: sh deploy/scripts/install.sh
# #
# This script must be run as root. It: # This script must be run as root. It:
# 1. Creates the mcias system user and group (idempotent). # 1. Creates the mcias system user and group (idempotent).
# 2. Copies binaries to /usr/local/bin/. # 2. Copies binaries to /usr/local/bin/.
# 3. Creates /srv/mcias/ with correct permissions. # 3. Creates /srv/mcias/ with correct permissions.
# 4. Installs the systemd service unit. # 4. Installs the systemd service and backup units.
# 5. Prints post-install instructions. # 5. Prints post-install instructions.
# #
# The script does NOT start or enable the service automatically. Review the # The script does NOT start or enable the service automatically. Review the
@@ -31,7 +31,8 @@ SYSTEMD_DIR="/etc/systemd/system"
SERVICE_USER="mcias" SERVICE_USER="mcias"
SERVICE_GROUP="mcias" SERVICE_GROUP="mcias"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(dirname "$SCRIPT_DIR")" DEPLOY_DIR="$(dirname "$SCRIPT_DIR")"
REPO_ROOT="$(dirname "$DEPLOY_DIR")"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
@@ -100,11 +101,7 @@ fi
# Step 2: Install binaries. # Step 2: Install binaries.
info "Installing binaries to $BIN_DIR" info "Installing binaries to $BIN_DIR"
for bin in mciassrv mciasctl mciasdb mciasgrpcctl; do for bin in mciassrv mciasctl mciasdb mciasgrpcctl; do
src="$REPO_ROOT/$bin" src="$REPO_ROOT/bin/$bin"
if [ ! -f "$src" ]; then
# Try bin/ subdirectory (Makefile build output).
src="$REPO_ROOT/bin/$bin"
fi
if [ ! -f "$src" ]; then if [ ! -f "$src" ]; then
warn "Binary not found: $bin — skipping. Run 'make build' first." warn "Binary not found: $bin — skipping. Run 'make build' first."
continue continue
@@ -113,30 +110,40 @@ for bin in mciassrv mciasctl mciasdb mciasgrpcctl; do
install -m 0755 -o root -g root "$src" "$BIN_DIR/$bin" install -m 0755 -o root -g root "$src" "$BIN_DIR/$bin"
done done
# Step 3: Create service directory. # Step 3: Create service directory structure.
info "Creating $SRV_DIR" info "Creating $SRV_DIR"
install -d -m 0750 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$SRV_DIR" install -d -m 0750 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$SRV_DIR"
install -d -m 0750 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$SRV_DIR/certs"
install -d -m 0750 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$SRV_DIR/backups"
# Install example config files; never overwrite existing configs. # Install example config files; never overwrite existing configs.
for f in mcias.conf.example mcias.env.example; do for f in mcias.conf.example mcias.env.example; do
src="$SCRIPT_DIR/$f" src="$DEPLOY_DIR/examples/$f"
dst="$SRV_DIR/$f" dst="$SRV_DIR/$f"
if [ -f "$src" ]; then if [ -f "$src" ]; then
install -m 0640 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$src" "$dst" 2>/dev/null || true install -m 0640 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$src" "$dst" 2>/dev/null || true
fi fi
done done
# Step 5: Install systemd service unit. # Step 4: Install systemd units.
if [ -d "$SYSTEMD_DIR" ]; then if [ -d "$SYSTEMD_DIR" ]; then
info "Installing systemd service unit to $SYSTEMD_DIR" info "Installing systemd units to $SYSTEMD_DIR"
install -m 0644 -o root -g root "$SCRIPT_DIR/mcias.service" "$SYSTEMD_DIR/mcias.service" for unit in mcias.service mcias-backup.service mcias-backup.timer; do
src="$DEPLOY_DIR/systemd/$unit"
if [ -f "$src" ]; then
install -m 0644 -o root -g root "$src" "$SYSTEMD_DIR/$unit"
info " Installed $unit"
fi
done
info "Reloading systemd daemon" info "Reloading systemd daemon"
systemctl daemon-reload 2>/dev/null || warn "systemctl not available; reload manually." systemctl daemon-reload 2>/dev/null || warn "systemctl not available; reload manually."
info "Enabling backup timer"
systemctl enable mcias-backup.timer 2>/dev/null || warn "Could not enable timer; enable manually with: systemctl enable mcias-backup.timer"
else else
warn "systemd not found at $SYSTEMD_DIR; skipping service unit installation." warn "systemd not found at $SYSTEMD_DIR; skipping service unit installation."
fi fi
# Step 6: Install man pages. # Step 5: Install man pages.
if [ -d "$REPO_ROOT/man/man1" ]; then if [ -d "$REPO_ROOT/man/man1" ]; then
install -d -m 0755 -o root -g root "$MAN_DIR" install -d -m 0755 -o root -g root "$MAN_DIR"
info "Installing man pages to $MAN_DIR" info "Installing man pages to $MAN_DIR"
@@ -170,12 +177,12 @@ Next steps:
# Self-signed (development / personal use): # Self-signed (development / personal use):
openssl req -x509 -newkey ed25519 -days 3650 \\ openssl req -x509 -newkey ed25519 -days 3650 \\
-keyout /srv/mcias/server.key \\ -keyout /srv/mcias/certs/server.key \\
-out /srv/mcias/server.crt \\ -out /srv/mcias/certs/server.crt \\
-subj "/CN=auth.example.com" \\ -subj "/CN=auth.example.com" \\
-nodes -nodes
chmod 0640 /srv/mcias/server.key chmod 0640 /srv/mcias/certs/server.key
chown mcias:mcias /srv/mcias/server.key /srv/mcias/server.crt chown mcias:mcias /srv/mcias/certs/server.key /srv/mcias/certs/server.crt
2. Copy and edit the configuration file: 2. Copy and edit the configuration file:
@@ -200,6 +207,9 @@ Next steps:
systemctl start mcias systemctl start mcias
systemctl status mcias systemctl status mcias
The backup timer was enabled automatically. Verify with:
systemctl status mcias-backup.timer
5. Create the first admin account using mciasdb (while the server is 5. Create the first admin account using mciasdb (while the server is
running, or before first start): running, or before first start):

View File

@@ -0,0 +1,32 @@
[Unit]
Description=MCIAS Database Backup
Documentation=man:mciasdb(1)
After=mcias.service
# Backup runs against the live database using VACUUM INTO, which is safe
# while mciassrv is running (WAL mode allows concurrent readers).
[Service]
Type=oneshot
User=mcias
Group=mcias
EnvironmentFile=/srv/mcias/env
ExecStart=/usr/local/bin/mciasdb -config /srv/mcias/mcias.toml snapshot
# Filesystem restrictions (read-write to /srv/mcias for the backup output).
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/srv/mcias
NoNewPrivileges=true
PrivateDevices=true
CapabilityBoundingSet=
RestrictSUIDSGID=true
RestrictNamespaces=true
RestrictRealtime=true
LockPersonality=true
MemoryDenyWriteExecute=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true

View File

@@ -0,0 +1,15 @@
[Unit]
Description=Daily MCIAS Database Backup
Documentation=man:mciasdb(1)
[Timer]
# Run daily at 02:00 UTC with up to 5-minute random jitter to avoid
# thundering-herd on systems with many services.
OnCalendar=*-*-* 02:00:00 UTC
RandomizedDelaySec=5min
# Run immediately on boot if the last scheduled run was missed
# (e.g. host was offline at 02:00).
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -12,7 +12,7 @@ Group=mcias
# Configuration and secrets. # Configuration and secrets.
# /srv/mcias/env must contain MCIAS_MASTER_PASSPHRASE=<passphrase> # /srv/mcias/env must contain MCIAS_MASTER_PASSPHRASE=<passphrase>
# See dist/mcias.env.example for the template. # See deploy/examples/mcias.env.example for the template.
EnvironmentFile=/srv/mcias/env EnvironmentFile=/srv/mcias/env
ExecStart=/usr/local/bin/mciassrv -config /srv/mcias/mcias.toml ExecStart=/usr/local/bin/mciassrv -config /srv/mcias/mcias.toml
@@ -42,6 +42,7 @@ PrivateDevices=true
ProtectKernelTunables=true ProtectKernelTunables=true
ProtectKernelModules=true ProtectKernelModules=true
ProtectControlGroups=true ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictNamespaces=true RestrictNamespaces=true
RestrictRealtime=true RestrictRealtime=true
LockPersonality=true LockPersonality=true

27
flake.lock generated Normal file
View File

@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1774273680,
"narHash": "sha256-a++tZ1RQsDb1I0NHrFwdGuRlR5TORvCEUksM459wKUA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "fdc7b8f7b30fdbedec91b71ed82f36e1637483ed",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

45
flake.nix Normal file
View File

@@ -0,0 +1,45 @@
{
description = "mcias - Metacircular Identity and Access Service";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
};
outputs =
{ self, nixpkgs }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
version = "1.8.0";
in
{
packages.${system} = {
default = pkgs.buildGoModule {
pname = "mciasctl";
inherit version;
src = ./.;
vendorHash = null;
subPackages = [
"cmd/mciasctl"
"cmd/mciasgrpcctl"
];
ldflags = [
"-s"
"-w"
"-X main.version=${version}"
];
postInstall = ''
mkdir -p $out/share/zsh/site-functions
mkdir -p $out/share/bash-completion/completions
mkdir -p $out/share/fish/vendor_completions.d
$out/bin/mciasctl completion zsh > $out/share/zsh/site-functions/_mciasctl
$out/bin/mciasctl completion bash > $out/share/bash-completion/completions/mciasctl
$out/bin/mciasctl completion fish > $out/share/fish/vendor_completions.d/mciasctl.fish
$out/bin/mciasgrpcctl completion zsh > $out/share/zsh/site-functions/_mciasgrpcctl
$out/bin/mciasgrpcctl completion bash > $out/share/bash-completion/completions/mciasgrpcctl
$out/bin/mciasgrpcctl completion fish > $out/share/fish/vendor_completions.d/mciasgrpcctl.fish
'';
};
};
};
}

View File

@@ -4,7 +4,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.11 // protoc-gen-go v1.36.11
// protoc v3.20.3 // protoc v6.32.1
// source: mcias/v1/account.proto // source: mcias/v1/account.proto
package mciasv1 package mciasv1
@@ -1080,7 +1080,7 @@ const file_mcias_v1_account_proto_rawDesc = "" +
"\n" + "\n" +
"GetPGCreds\x12\x1b.mcias.v1.GetPGCredsRequest\x1a\x1c.mcias.v1.GetPGCredsResponse\x12G\n" + "GetPGCreds\x12\x1b.mcias.v1.GetPGCredsRequest\x1a\x1c.mcias.v1.GetPGCredsResponse\x12G\n" +
"\n" + "\n" +
"SetPGCreds\x12\x1b.mcias.v1.SetPGCredsRequest\x1a\x1c.mcias.v1.SetPGCredsResponseB2Z0git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1b\x06proto3" "SetPGCreds\x12\x1b.mcias.v1.SetPGCredsRequest\x1a\x1c.mcias.v1.SetPGCredsResponseB0Z.git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3"
var ( var (
file_mcias_v1_account_proto_rawDescOnce sync.Once file_mcias_v1_account_proto_rawDescOnce sync.Once

View File

@@ -4,7 +4,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.1 // - protoc-gen-go-grpc v1.6.1
// - protoc v3.20.3 // - protoc v6.32.1
// source: mcias/v1/account.proto // source: mcias/v1/account.proto
package mciasv1 package mciasv1

View File

@@ -4,7 +4,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.11 // protoc-gen-go v1.36.11
// protoc v3.20.3 // protoc v6.32.1
// source: mcias/v1/admin.proto // source: mcias/v1/admin.proto
package mciasv1 package mciasv1
@@ -238,7 +238,7 @@ const file_mcias_v1_admin_proto_rawDesc = "" +
"\x01x\x18\x05 \x01(\tR\x01x2\x9a\x01\n" + "\x01x\x18\x05 \x01(\tR\x01x2\x9a\x01\n" +
"\fAdminService\x12;\n" + "\fAdminService\x12;\n" +
"\x06Health\x12\x17.mcias.v1.HealthRequest\x1a\x18.mcias.v1.HealthResponse\x12M\n" + "\x06Health\x12\x17.mcias.v1.HealthRequest\x1a\x18.mcias.v1.HealthResponse\x12M\n" +
"\fGetPublicKey\x12\x1d.mcias.v1.GetPublicKeyRequest\x1a\x1e.mcias.v1.GetPublicKeyResponseB2Z0git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1b\x06proto3" "\fGetPublicKey\x12\x1d.mcias.v1.GetPublicKeyRequest\x1a\x1e.mcias.v1.GetPublicKeyResponseB0Z.git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3"
var ( var (
file_mcias_v1_admin_proto_rawDescOnce sync.Once file_mcias_v1_admin_proto_rawDescOnce sync.Once

View File

@@ -4,7 +4,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.1 // - protoc-gen-go-grpc v1.6.1
// - protoc v3.20.3 // - protoc v6.32.1
// source: mcias/v1/admin.proto // source: mcias/v1/admin.proto
package mciasv1 package mciasv1

View File

@@ -3,7 +3,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.11 // protoc-gen-go v1.36.11
// protoc v3.20.3 // protoc v6.32.1
// source: mcias/v1/auth.proto // source: mcias/v1/auth.proto
package mciasv1 package mciasv1
@@ -919,7 +919,7 @@ const file_mcias_v1_auth_proto_rawDesc = "" +
"\n" + "\n" +
"RemoveTOTP\x12\x1b.mcias.v1.RemoveTOTPRequest\x1a\x1c.mcias.v1.RemoveTOTPResponse\x12n\n" + "RemoveTOTP\x12\x1b.mcias.v1.RemoveTOTPRequest\x1a\x1c.mcias.v1.RemoveTOTPResponse\x12n\n" +
"\x17ListWebAuthnCredentials\x12(.mcias.v1.ListWebAuthnCredentialsRequest\x1a).mcias.v1.ListWebAuthnCredentialsResponse\x12q\n" + "\x17ListWebAuthnCredentials\x12(.mcias.v1.ListWebAuthnCredentialsRequest\x1a).mcias.v1.ListWebAuthnCredentialsResponse\x12q\n" +
"\x18RemoveWebAuthnCredential\x12).mcias.v1.RemoveWebAuthnCredentialRequest\x1a*.mcias.v1.RemoveWebAuthnCredentialResponseB2Z0git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1b\x06proto3" "\x18RemoveWebAuthnCredential\x12).mcias.v1.RemoveWebAuthnCredentialRequest\x1a*.mcias.v1.RemoveWebAuthnCredentialResponseB0Z.git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3"
var ( var (
file_mcias_v1_auth_proto_rawDescOnce sync.Once file_mcias_v1_auth_proto_rawDescOnce sync.Once

View File

@@ -3,7 +3,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.1 // - protoc-gen-go-grpc v1.6.1
// - protoc v3.20.3 // - protoc v6.32.1
// source: mcias/v1/auth.proto // source: mcias/v1/auth.proto
package mciasv1 package mciasv1

View File

@@ -3,7 +3,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.11 // protoc-gen-go v1.36.11
// protoc v3.20.3 // protoc v6.32.1
// source: mcias/v1/common.proto // source: mcias/v1/common.proto
package mciasv1 package mciasv1
@@ -349,7 +349,7 @@ const file_mcias_v1_common_proto_rawDesc = "" +
"\x04port\x18\x05 \x01(\x05R\x04port\"5\n" + "\x04port\x18\x05 \x01(\x05R\x04port\"5\n" +
"\x05Error\x12\x18\n" + "\x05Error\x12\x18\n" +
"\amessage\x18\x01 \x01(\tR\amessage\x12\x12\n" + "\amessage\x18\x01 \x01(\tR\amessage\x12\x12\n" +
"\x04code\x18\x02 \x01(\tR\x04codeB2Z0git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1b\x06proto3" "\x04code\x18\x02 \x01(\tR\x04codeB0Z.git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3"
var ( var (
file_mcias_v1_common_proto_rawDescOnce sync.Once file_mcias_v1_common_proto_rawDescOnce sync.Once

View File

@@ -3,7 +3,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.11 // protoc-gen-go v1.36.11
// protoc v3.20.3 // protoc v6.32.1
// source: mcias/v1/policy.proto // source: mcias/v1/policy.proto
package mciasv1 package mciasv1
@@ -703,7 +703,7 @@ const file_mcias_v1_policy_proto_rawDesc = "" +
"\x10CreatePolicyRule\x12!.mcias.v1.CreatePolicyRuleRequest\x1a\".mcias.v1.CreatePolicyRuleResponse\x12P\n" + "\x10CreatePolicyRule\x12!.mcias.v1.CreatePolicyRuleRequest\x1a\".mcias.v1.CreatePolicyRuleResponse\x12P\n" +
"\rGetPolicyRule\x12\x1e.mcias.v1.GetPolicyRuleRequest\x1a\x1f.mcias.v1.GetPolicyRuleResponse\x12Y\n" + "\rGetPolicyRule\x12\x1e.mcias.v1.GetPolicyRuleRequest\x1a\x1f.mcias.v1.GetPolicyRuleResponse\x12Y\n" +
"\x10UpdatePolicyRule\x12!.mcias.v1.UpdatePolicyRuleRequest\x1a\".mcias.v1.UpdatePolicyRuleResponse\x12Y\n" + "\x10UpdatePolicyRule\x12!.mcias.v1.UpdatePolicyRuleRequest\x1a\".mcias.v1.UpdatePolicyRuleResponse\x12Y\n" +
"\x10DeletePolicyRule\x12!.mcias.v1.DeletePolicyRuleRequest\x1a\".mcias.v1.DeletePolicyRuleResponseB2Z0git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1b\x06proto3" "\x10DeletePolicyRule\x12!.mcias.v1.DeletePolicyRuleRequest\x1a\".mcias.v1.DeletePolicyRuleResponseB0Z.git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3"
var ( var (
file_mcias_v1_policy_proto_rawDescOnce sync.Once file_mcias_v1_policy_proto_rawDescOnce sync.Once

View File

@@ -3,7 +3,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.1 // - protoc-gen-go-grpc v1.6.1
// - protoc v3.20.3 // - protoc v6.32.1
// source: mcias/v1/policy.proto // source: mcias/v1/policy.proto
package mciasv1 package mciasv1

View File

@@ -0,0 +1,703 @@
// SSOClientService: CRUD management of SSO client registrations.
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v6.32.1
// source: mcias/v1/sso_client.proto
package mciasv1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
// SSOClient is the wire representation of an SSO client registration.
type SSOClient struct {
state protoimpl.MessageState `protogen:"open.v1"`
ClientId string `protobuf:"bytes,1,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"`
RedirectUri string `protobuf:"bytes,2,opt,name=redirect_uri,json=redirectUri,proto3" json:"redirect_uri,omitempty"`
Tags []string `protobuf:"bytes,3,rep,name=tags,proto3" json:"tags,omitempty"`
Enabled bool `protobuf:"varint,4,opt,name=enabled,proto3" json:"enabled,omitempty"`
CreatedAt string `protobuf:"bytes,5,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` // RFC3339
UpdatedAt string `protobuf:"bytes,6,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` // RFC3339
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SSOClient) Reset() {
*x = SSOClient{}
mi := &file_mcias_v1_sso_client_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SSOClient) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SSOClient) ProtoMessage() {}
func (x *SSOClient) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_sso_client_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SSOClient.ProtoReflect.Descriptor instead.
func (*SSOClient) Descriptor() ([]byte, []int) {
return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{0}
}
func (x *SSOClient) GetClientId() string {
if x != nil {
return x.ClientId
}
return ""
}
func (x *SSOClient) GetRedirectUri() string {
if x != nil {
return x.RedirectUri
}
return ""
}
func (x *SSOClient) GetTags() []string {
if x != nil {
return x.Tags
}
return nil
}
func (x *SSOClient) GetEnabled() bool {
if x != nil {
return x.Enabled
}
return false
}
func (x *SSOClient) GetCreatedAt() string {
if x != nil {
return x.CreatedAt
}
return ""
}
func (x *SSOClient) GetUpdatedAt() string {
if x != nil {
return x.UpdatedAt
}
return ""
}
type ListSSOClientsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListSSOClientsRequest) Reset() {
*x = ListSSOClientsRequest{}
mi := &file_mcias_v1_sso_client_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListSSOClientsRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListSSOClientsRequest) ProtoMessage() {}
func (x *ListSSOClientsRequest) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_sso_client_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListSSOClientsRequest.ProtoReflect.Descriptor instead.
func (*ListSSOClientsRequest) Descriptor() ([]byte, []int) {
return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{1}
}
type ListSSOClientsResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Clients []*SSOClient `protobuf:"bytes,1,rep,name=clients,proto3" json:"clients,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListSSOClientsResponse) Reset() {
*x = ListSSOClientsResponse{}
mi := &file_mcias_v1_sso_client_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListSSOClientsResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListSSOClientsResponse) ProtoMessage() {}
func (x *ListSSOClientsResponse) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_sso_client_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListSSOClientsResponse.ProtoReflect.Descriptor instead.
func (*ListSSOClientsResponse) Descriptor() ([]byte, []int) {
return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{2}
}
func (x *ListSSOClientsResponse) GetClients() []*SSOClient {
if x != nil {
return x.Clients
}
return nil
}
type CreateSSOClientRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
ClientId string `protobuf:"bytes,1,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"`
RedirectUri string `protobuf:"bytes,2,opt,name=redirect_uri,json=redirectUri,proto3" json:"redirect_uri,omitempty"`
Tags []string `protobuf:"bytes,3,rep,name=tags,proto3" json:"tags,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CreateSSOClientRequest) Reset() {
*x = CreateSSOClientRequest{}
mi := &file_mcias_v1_sso_client_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CreateSSOClientRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CreateSSOClientRequest) ProtoMessage() {}
func (x *CreateSSOClientRequest) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_sso_client_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CreateSSOClientRequest.ProtoReflect.Descriptor instead.
func (*CreateSSOClientRequest) Descriptor() ([]byte, []int) {
return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{3}
}
func (x *CreateSSOClientRequest) GetClientId() string {
if x != nil {
return x.ClientId
}
return ""
}
func (x *CreateSSOClientRequest) GetRedirectUri() string {
if x != nil {
return x.RedirectUri
}
return ""
}
func (x *CreateSSOClientRequest) GetTags() []string {
if x != nil {
return x.Tags
}
return nil
}
type CreateSSOClientResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Client *SSOClient `protobuf:"bytes,1,opt,name=client,proto3" json:"client,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CreateSSOClientResponse) Reset() {
*x = CreateSSOClientResponse{}
mi := &file_mcias_v1_sso_client_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CreateSSOClientResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CreateSSOClientResponse) ProtoMessage() {}
func (x *CreateSSOClientResponse) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_sso_client_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CreateSSOClientResponse.ProtoReflect.Descriptor instead.
func (*CreateSSOClientResponse) Descriptor() ([]byte, []int) {
return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{4}
}
func (x *CreateSSOClientResponse) GetClient() *SSOClient {
if x != nil {
return x.Client
}
return nil
}
type GetSSOClientRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
ClientId string `protobuf:"bytes,1,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetSSOClientRequest) Reset() {
*x = GetSSOClientRequest{}
mi := &file_mcias_v1_sso_client_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetSSOClientRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetSSOClientRequest) ProtoMessage() {}
func (x *GetSSOClientRequest) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_sso_client_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetSSOClientRequest.ProtoReflect.Descriptor instead.
func (*GetSSOClientRequest) Descriptor() ([]byte, []int) {
return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{5}
}
func (x *GetSSOClientRequest) GetClientId() string {
if x != nil {
return x.ClientId
}
return ""
}
type GetSSOClientResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Client *SSOClient `protobuf:"bytes,1,opt,name=client,proto3" json:"client,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetSSOClientResponse) Reset() {
*x = GetSSOClientResponse{}
mi := &file_mcias_v1_sso_client_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetSSOClientResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetSSOClientResponse) ProtoMessage() {}
func (x *GetSSOClientResponse) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_sso_client_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetSSOClientResponse.ProtoReflect.Descriptor instead.
func (*GetSSOClientResponse) Descriptor() ([]byte, []int) {
return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{6}
}
func (x *GetSSOClientResponse) GetClient() *SSOClient {
if x != nil {
return x.Client
}
return nil
}
type UpdateSSOClientRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
ClientId string `protobuf:"bytes,1,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"`
RedirectUri *string `protobuf:"bytes,2,opt,name=redirect_uri,json=redirectUri,proto3,oneof" json:"redirect_uri,omitempty"`
Tags []string `protobuf:"bytes,3,rep,name=tags,proto3" json:"tags,omitempty"`
Enabled *bool `protobuf:"varint,4,opt,name=enabled,proto3,oneof" json:"enabled,omitempty"`
UpdateTags bool `protobuf:"varint,5,opt,name=update_tags,json=updateTags,proto3" json:"update_tags,omitempty"` // when true, tags field is applied (allows clearing)
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *UpdateSSOClientRequest) Reset() {
*x = UpdateSSOClientRequest{}
mi := &file_mcias_v1_sso_client_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *UpdateSSOClientRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*UpdateSSOClientRequest) ProtoMessage() {}
func (x *UpdateSSOClientRequest) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_sso_client_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use UpdateSSOClientRequest.ProtoReflect.Descriptor instead.
func (*UpdateSSOClientRequest) Descriptor() ([]byte, []int) {
return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{7}
}
func (x *UpdateSSOClientRequest) GetClientId() string {
if x != nil {
return x.ClientId
}
return ""
}
func (x *UpdateSSOClientRequest) GetRedirectUri() string {
if x != nil && x.RedirectUri != nil {
return *x.RedirectUri
}
return ""
}
func (x *UpdateSSOClientRequest) GetTags() []string {
if x != nil {
return x.Tags
}
return nil
}
func (x *UpdateSSOClientRequest) GetEnabled() bool {
if x != nil && x.Enabled != nil {
return *x.Enabled
}
return false
}
func (x *UpdateSSOClientRequest) GetUpdateTags() bool {
if x != nil {
return x.UpdateTags
}
return false
}
type UpdateSSOClientResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Client *SSOClient `protobuf:"bytes,1,opt,name=client,proto3" json:"client,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *UpdateSSOClientResponse) Reset() {
*x = UpdateSSOClientResponse{}
mi := &file_mcias_v1_sso_client_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *UpdateSSOClientResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*UpdateSSOClientResponse) ProtoMessage() {}
func (x *UpdateSSOClientResponse) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_sso_client_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use UpdateSSOClientResponse.ProtoReflect.Descriptor instead.
func (*UpdateSSOClientResponse) Descriptor() ([]byte, []int) {
return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{8}
}
func (x *UpdateSSOClientResponse) GetClient() *SSOClient {
if x != nil {
return x.Client
}
return nil
}
type DeleteSSOClientRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
ClientId string `protobuf:"bytes,1,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DeleteSSOClientRequest) Reset() {
*x = DeleteSSOClientRequest{}
mi := &file_mcias_v1_sso_client_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DeleteSSOClientRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DeleteSSOClientRequest) ProtoMessage() {}
func (x *DeleteSSOClientRequest) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_sso_client_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DeleteSSOClientRequest.ProtoReflect.Descriptor instead.
func (*DeleteSSOClientRequest) Descriptor() ([]byte, []int) {
return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{9}
}
func (x *DeleteSSOClientRequest) GetClientId() string {
if x != nil {
return x.ClientId
}
return ""
}
type DeleteSSOClientResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DeleteSSOClientResponse) Reset() {
*x = DeleteSSOClientResponse{}
mi := &file_mcias_v1_sso_client_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DeleteSSOClientResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DeleteSSOClientResponse) ProtoMessage() {}
func (x *DeleteSSOClientResponse) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_sso_client_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DeleteSSOClientResponse.ProtoReflect.Descriptor instead.
func (*DeleteSSOClientResponse) Descriptor() ([]byte, []int) {
return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{10}
}
var File_mcias_v1_sso_client_proto protoreflect.FileDescriptor
const file_mcias_v1_sso_client_proto_rawDesc = "" +
"\n" +
"\x19mcias/v1/sso_client.proto\x12\bmcias.v1\"\xb7\x01\n" +
"\tSSOClient\x12\x1b\n" +
"\tclient_id\x18\x01 \x01(\tR\bclientId\x12!\n" +
"\fredirect_uri\x18\x02 \x01(\tR\vredirectUri\x12\x12\n" +
"\x04tags\x18\x03 \x03(\tR\x04tags\x12\x18\n" +
"\aenabled\x18\x04 \x01(\bR\aenabled\x12\x1d\n" +
"\n" +
"created_at\x18\x05 \x01(\tR\tcreatedAt\x12\x1d\n" +
"\n" +
"updated_at\x18\x06 \x01(\tR\tupdatedAt\"\x17\n" +
"\x15ListSSOClientsRequest\"G\n" +
"\x16ListSSOClientsResponse\x12-\n" +
"\aclients\x18\x01 \x03(\v2\x13.mcias.v1.SSOClientR\aclients\"l\n" +
"\x16CreateSSOClientRequest\x12\x1b\n" +
"\tclient_id\x18\x01 \x01(\tR\bclientId\x12!\n" +
"\fredirect_uri\x18\x02 \x01(\tR\vredirectUri\x12\x12\n" +
"\x04tags\x18\x03 \x03(\tR\x04tags\"F\n" +
"\x17CreateSSOClientResponse\x12+\n" +
"\x06client\x18\x01 \x01(\v2\x13.mcias.v1.SSOClientR\x06client\"2\n" +
"\x13GetSSOClientRequest\x12\x1b\n" +
"\tclient_id\x18\x01 \x01(\tR\bclientId\"C\n" +
"\x14GetSSOClientResponse\x12+\n" +
"\x06client\x18\x01 \x01(\v2\x13.mcias.v1.SSOClientR\x06client\"\xce\x01\n" +
"\x16UpdateSSOClientRequest\x12\x1b\n" +
"\tclient_id\x18\x01 \x01(\tR\bclientId\x12&\n" +
"\fredirect_uri\x18\x02 \x01(\tH\x00R\vredirectUri\x88\x01\x01\x12\x12\n" +
"\x04tags\x18\x03 \x03(\tR\x04tags\x12\x1d\n" +
"\aenabled\x18\x04 \x01(\bH\x01R\aenabled\x88\x01\x01\x12\x1f\n" +
"\vupdate_tags\x18\x05 \x01(\bR\n" +
"updateTagsB\x0f\n" +
"\r_redirect_uriB\n" +
"\n" +
"\b_enabled\"F\n" +
"\x17UpdateSSOClientResponse\x12+\n" +
"\x06client\x18\x01 \x01(\v2\x13.mcias.v1.SSOClientR\x06client\"5\n" +
"\x16DeleteSSOClientRequest\x12\x1b\n" +
"\tclient_id\x18\x01 \x01(\tR\bclientId\"\x19\n" +
"\x17DeleteSSOClientResponse2\xbe\x03\n" +
"\x10SSOClientService\x12S\n" +
"\x0eListSSOClients\x12\x1f.mcias.v1.ListSSOClientsRequest\x1a .mcias.v1.ListSSOClientsResponse\x12V\n" +
"\x0fCreateSSOClient\x12 .mcias.v1.CreateSSOClientRequest\x1a!.mcias.v1.CreateSSOClientResponse\x12M\n" +
"\fGetSSOClient\x12\x1d.mcias.v1.GetSSOClientRequest\x1a\x1e.mcias.v1.GetSSOClientResponse\x12V\n" +
"\x0fUpdateSSOClient\x12 .mcias.v1.UpdateSSOClientRequest\x1a!.mcias.v1.UpdateSSOClientResponse\x12V\n" +
"\x0fDeleteSSOClient\x12 .mcias.v1.DeleteSSOClientRequest\x1a!.mcias.v1.DeleteSSOClientResponseB0Z.git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3"
var (
file_mcias_v1_sso_client_proto_rawDescOnce sync.Once
file_mcias_v1_sso_client_proto_rawDescData []byte
)
func file_mcias_v1_sso_client_proto_rawDescGZIP() []byte {
file_mcias_v1_sso_client_proto_rawDescOnce.Do(func() {
file_mcias_v1_sso_client_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_mcias_v1_sso_client_proto_rawDesc), len(file_mcias_v1_sso_client_proto_rawDesc)))
})
return file_mcias_v1_sso_client_proto_rawDescData
}
var file_mcias_v1_sso_client_proto_msgTypes = make([]protoimpl.MessageInfo, 11)
var file_mcias_v1_sso_client_proto_goTypes = []any{
(*SSOClient)(nil), // 0: mcias.v1.SSOClient
(*ListSSOClientsRequest)(nil), // 1: mcias.v1.ListSSOClientsRequest
(*ListSSOClientsResponse)(nil), // 2: mcias.v1.ListSSOClientsResponse
(*CreateSSOClientRequest)(nil), // 3: mcias.v1.CreateSSOClientRequest
(*CreateSSOClientResponse)(nil), // 4: mcias.v1.CreateSSOClientResponse
(*GetSSOClientRequest)(nil), // 5: mcias.v1.GetSSOClientRequest
(*GetSSOClientResponse)(nil), // 6: mcias.v1.GetSSOClientResponse
(*UpdateSSOClientRequest)(nil), // 7: mcias.v1.UpdateSSOClientRequest
(*UpdateSSOClientResponse)(nil), // 8: mcias.v1.UpdateSSOClientResponse
(*DeleteSSOClientRequest)(nil), // 9: mcias.v1.DeleteSSOClientRequest
(*DeleteSSOClientResponse)(nil), // 10: mcias.v1.DeleteSSOClientResponse
}
var file_mcias_v1_sso_client_proto_depIdxs = []int32{
0, // 0: mcias.v1.ListSSOClientsResponse.clients:type_name -> mcias.v1.SSOClient
0, // 1: mcias.v1.CreateSSOClientResponse.client:type_name -> mcias.v1.SSOClient
0, // 2: mcias.v1.GetSSOClientResponse.client:type_name -> mcias.v1.SSOClient
0, // 3: mcias.v1.UpdateSSOClientResponse.client:type_name -> mcias.v1.SSOClient
1, // 4: mcias.v1.SSOClientService.ListSSOClients:input_type -> mcias.v1.ListSSOClientsRequest
3, // 5: mcias.v1.SSOClientService.CreateSSOClient:input_type -> mcias.v1.CreateSSOClientRequest
5, // 6: mcias.v1.SSOClientService.GetSSOClient:input_type -> mcias.v1.GetSSOClientRequest
7, // 7: mcias.v1.SSOClientService.UpdateSSOClient:input_type -> mcias.v1.UpdateSSOClientRequest
9, // 8: mcias.v1.SSOClientService.DeleteSSOClient:input_type -> mcias.v1.DeleteSSOClientRequest
2, // 9: mcias.v1.SSOClientService.ListSSOClients:output_type -> mcias.v1.ListSSOClientsResponse
4, // 10: mcias.v1.SSOClientService.CreateSSOClient:output_type -> mcias.v1.CreateSSOClientResponse
6, // 11: mcias.v1.SSOClientService.GetSSOClient:output_type -> mcias.v1.GetSSOClientResponse
8, // 12: mcias.v1.SSOClientService.UpdateSSOClient:output_type -> mcias.v1.UpdateSSOClientResponse
10, // 13: mcias.v1.SSOClientService.DeleteSSOClient:output_type -> mcias.v1.DeleteSSOClientResponse
9, // [9:14] is the sub-list for method output_type
4, // [4:9] is the sub-list for method input_type
4, // [4:4] is the sub-list for extension type_name
4, // [4:4] is the sub-list for extension extendee
0, // [0:4] is the sub-list for field type_name
}
func init() { file_mcias_v1_sso_client_proto_init() }
func file_mcias_v1_sso_client_proto_init() {
if File_mcias_v1_sso_client_proto != nil {
return
}
file_mcias_v1_sso_client_proto_msgTypes[7].OneofWrappers = []any{}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_mcias_v1_sso_client_proto_rawDesc), len(file_mcias_v1_sso_client_proto_rawDesc)),
NumEnums: 0,
NumMessages: 11,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_mcias_v1_sso_client_proto_goTypes,
DependencyIndexes: file_mcias_v1_sso_client_proto_depIdxs,
MessageInfos: file_mcias_v1_sso_client_proto_msgTypes,
}.Build()
File_mcias_v1_sso_client_proto = out.File
file_mcias_v1_sso_client_proto_goTypes = nil
file_mcias_v1_sso_client_proto_depIdxs = nil
}

View File

@@ -0,0 +1,289 @@
// SSOClientService: CRUD management of SSO client registrations.
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v6.32.1
// source: mcias/v1/sso_client.proto
package mciasv1
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
SSOClientService_ListSSOClients_FullMethodName = "/mcias.v1.SSOClientService/ListSSOClients"
SSOClientService_CreateSSOClient_FullMethodName = "/mcias.v1.SSOClientService/CreateSSOClient"
SSOClientService_GetSSOClient_FullMethodName = "/mcias.v1.SSOClientService/GetSSOClient"
SSOClientService_UpdateSSOClient_FullMethodName = "/mcias.v1.SSOClientService/UpdateSSOClient"
SSOClientService_DeleteSSOClient_FullMethodName = "/mcias.v1.SSOClientService/DeleteSSOClient"
)
// SSOClientServiceClient is the client API for SSOClientService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// SSOClientService manages SSO client registrations (admin only).
type SSOClientServiceClient interface {
// ListSSOClients returns all registered SSO clients.
ListSSOClients(ctx context.Context, in *ListSSOClientsRequest, opts ...grpc.CallOption) (*ListSSOClientsResponse, error)
// CreateSSOClient registers a new SSO client.
CreateSSOClient(ctx context.Context, in *CreateSSOClientRequest, opts ...grpc.CallOption) (*CreateSSOClientResponse, error)
// GetSSOClient returns a single SSO client by client_id.
GetSSOClient(ctx context.Context, in *GetSSOClientRequest, opts ...grpc.CallOption) (*GetSSOClientResponse, error)
// UpdateSSOClient applies a partial update to an SSO client.
UpdateSSOClient(ctx context.Context, in *UpdateSSOClientRequest, opts ...grpc.CallOption) (*UpdateSSOClientResponse, error)
// DeleteSSOClient removes an SSO client registration.
DeleteSSOClient(ctx context.Context, in *DeleteSSOClientRequest, opts ...grpc.CallOption) (*DeleteSSOClientResponse, error)
}
type sSOClientServiceClient struct {
cc grpc.ClientConnInterface
}
func NewSSOClientServiceClient(cc grpc.ClientConnInterface) SSOClientServiceClient {
return &sSOClientServiceClient{cc}
}
func (c *sSOClientServiceClient) ListSSOClients(ctx context.Context, in *ListSSOClientsRequest, opts ...grpc.CallOption) (*ListSSOClientsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListSSOClientsResponse)
err := c.cc.Invoke(ctx, SSOClientService_ListSSOClients_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *sSOClientServiceClient) CreateSSOClient(ctx context.Context, in *CreateSSOClientRequest, opts ...grpc.CallOption) (*CreateSSOClientResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CreateSSOClientResponse)
err := c.cc.Invoke(ctx, SSOClientService_CreateSSOClient_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *sSOClientServiceClient) GetSSOClient(ctx context.Context, in *GetSSOClientRequest, opts ...grpc.CallOption) (*GetSSOClientResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetSSOClientResponse)
err := c.cc.Invoke(ctx, SSOClientService_GetSSOClient_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *sSOClientServiceClient) UpdateSSOClient(ctx context.Context, in *UpdateSSOClientRequest, opts ...grpc.CallOption) (*UpdateSSOClientResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpdateSSOClientResponse)
err := c.cc.Invoke(ctx, SSOClientService_UpdateSSOClient_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *sSOClientServiceClient) DeleteSSOClient(ctx context.Context, in *DeleteSSOClientRequest, opts ...grpc.CallOption) (*DeleteSSOClientResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeleteSSOClientResponse)
err := c.cc.Invoke(ctx, SSOClientService_DeleteSSOClient_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// SSOClientServiceServer is the server API for SSOClientService service.
// All implementations must embed UnimplementedSSOClientServiceServer
// for forward compatibility.
//
// SSOClientService manages SSO client registrations (admin only).
type SSOClientServiceServer interface {
// ListSSOClients returns all registered SSO clients.
ListSSOClients(context.Context, *ListSSOClientsRequest) (*ListSSOClientsResponse, error)
// CreateSSOClient registers a new SSO client.
CreateSSOClient(context.Context, *CreateSSOClientRequest) (*CreateSSOClientResponse, error)
// GetSSOClient returns a single SSO client by client_id.
GetSSOClient(context.Context, *GetSSOClientRequest) (*GetSSOClientResponse, error)
// UpdateSSOClient applies a partial update to an SSO client.
UpdateSSOClient(context.Context, *UpdateSSOClientRequest) (*UpdateSSOClientResponse, error)
// DeleteSSOClient removes an SSO client registration.
DeleteSSOClient(context.Context, *DeleteSSOClientRequest) (*DeleteSSOClientResponse, error)
mustEmbedUnimplementedSSOClientServiceServer()
}
// UnimplementedSSOClientServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedSSOClientServiceServer struct{}
func (UnimplementedSSOClientServiceServer) ListSSOClients(context.Context, *ListSSOClientsRequest) (*ListSSOClientsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListSSOClients not implemented")
}
func (UnimplementedSSOClientServiceServer) CreateSSOClient(context.Context, *CreateSSOClientRequest) (*CreateSSOClientResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CreateSSOClient not implemented")
}
func (UnimplementedSSOClientServiceServer) GetSSOClient(context.Context, *GetSSOClientRequest) (*GetSSOClientResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetSSOClient not implemented")
}
func (UnimplementedSSOClientServiceServer) UpdateSSOClient(context.Context, *UpdateSSOClientRequest) (*UpdateSSOClientResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpdateSSOClient not implemented")
}
func (UnimplementedSSOClientServiceServer) DeleteSSOClient(context.Context, *DeleteSSOClientRequest) (*DeleteSSOClientResponse, error) {
return nil, status.Error(codes.Unimplemented, "method DeleteSSOClient not implemented")
}
func (UnimplementedSSOClientServiceServer) mustEmbedUnimplementedSSOClientServiceServer() {}
func (UnimplementedSSOClientServiceServer) testEmbeddedByValue() {}
// UnsafeSSOClientServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to SSOClientServiceServer will
// result in compilation errors.
type UnsafeSSOClientServiceServer interface {
mustEmbedUnimplementedSSOClientServiceServer()
}
func RegisterSSOClientServiceServer(s grpc.ServiceRegistrar, srv SSOClientServiceServer) {
// If the following call panics, it indicates UnimplementedSSOClientServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&SSOClientService_ServiceDesc, srv)
}
func _SSOClientService_ListSSOClients_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListSSOClientsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SSOClientServiceServer).ListSSOClients(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SSOClientService_ListSSOClients_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SSOClientServiceServer).ListSSOClients(ctx, req.(*ListSSOClientsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SSOClientService_CreateSSOClient_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateSSOClientRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SSOClientServiceServer).CreateSSOClient(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SSOClientService_CreateSSOClient_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SSOClientServiceServer).CreateSSOClient(ctx, req.(*CreateSSOClientRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SSOClientService_GetSSOClient_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetSSOClientRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SSOClientServiceServer).GetSSOClient(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SSOClientService_GetSSOClient_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SSOClientServiceServer).GetSSOClient(ctx, req.(*GetSSOClientRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SSOClientService_UpdateSSOClient_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateSSOClientRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SSOClientServiceServer).UpdateSSOClient(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SSOClientService_UpdateSSOClient_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SSOClientServiceServer).UpdateSSOClient(ctx, req.(*UpdateSSOClientRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SSOClientService_DeleteSSOClient_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteSSOClientRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SSOClientServiceServer).DeleteSSOClient(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SSOClientService_DeleteSSOClient_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SSOClientServiceServer).DeleteSSOClient(ctx, req.(*DeleteSSOClientRequest))
}
return interceptor(ctx, in, info, handler)
}
// SSOClientService_ServiceDesc is the grpc.ServiceDesc for SSOClientService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var SSOClientService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "mcias.v1.SSOClientService",
HandlerType: (*SSOClientServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ListSSOClients",
Handler: _SSOClientService_ListSSOClients_Handler,
},
{
MethodName: "CreateSSOClient",
Handler: _SSOClientService_CreateSSOClient_Handler,
},
{
MethodName: "GetSSOClient",
Handler: _SSOClientService_GetSSOClient_Handler,
},
{
MethodName: "UpdateSSOClient",
Handler: _SSOClientService_UpdateSSOClient_Handler,
},
{
MethodName: "DeleteSSOClient",
Handler: _SSOClientService_DeleteSSOClient_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "mcias/v1/sso_client.proto",
}

View File

@@ -3,7 +3,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.11 // protoc-gen-go v1.36.11
// protoc v3.20.3 // protoc v6.32.1
// source: mcias/v1/token.proto // source: mcias/v1/token.proto
package mciasv1 package mciasv1
@@ -346,7 +346,7 @@ const file_mcias_v1_token_proto_rawDesc = "" +
"\fTokenService\x12P\n" + "\fTokenService\x12P\n" +
"\rValidateToken\x12\x1e.mcias.v1.ValidateTokenRequest\x1a\x1f.mcias.v1.ValidateTokenResponse\x12\\\n" + "\rValidateToken\x12\x1e.mcias.v1.ValidateTokenRequest\x1a\x1f.mcias.v1.ValidateTokenResponse\x12\\\n" +
"\x11IssueServiceToken\x12\".mcias.v1.IssueServiceTokenRequest\x1a#.mcias.v1.IssueServiceTokenResponse\x12J\n" + "\x11IssueServiceToken\x12\".mcias.v1.IssueServiceTokenRequest\x1a#.mcias.v1.IssueServiceTokenResponse\x12J\n" +
"\vRevokeToken\x12\x1c.mcias.v1.RevokeTokenRequest\x1a\x1d.mcias.v1.RevokeTokenResponseB2Z0git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1b\x06proto3" "\vRevokeToken\x12\x1c.mcias.v1.RevokeTokenRequest\x1a\x1d.mcias.v1.RevokeTokenResponseB0Z.git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3"
var ( var (
file_mcias_v1_token_proto_rawDescOnce sync.Once file_mcias_v1_token_proto_rawDescOnce sync.Once

View File

@@ -3,7 +3,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.1 // - protoc-gen-go-grpc v1.6.1
// - protoc v3.20.3 // - protoc v6.32.1
// source: mcias/v1/token.proto // source: mcias/v1/token.proto
package mciasv1 package mciasv1

26
go.mod
View File

@@ -1,38 +1,40 @@
module git.wntrmute.dev/kyle/mcias module git.wntrmute.dev/mc/mcias
go 1.26.0 go 1.26.0
require ( require (
git.wntrmute.dev/mc/mcdsl v1.4.0
github.com/go-webauthn/webauthn v0.16.1
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/golang-migrate/migrate/v4 v4.19.1 github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/pelletier/go-toml/v2 v2.2.4 github.com/pelletier/go-toml/v2 v2.3.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/spf13/cobra v1.10.2
golang.org/x/crypto v0.49.0 golang.org/x/crypto v0.49.0
golang.org/x/term v0.41.0 google.golang.org/grpc v1.79.3
google.golang.org/grpc v1.74.2 google.golang.org/protobuf v1.36.11
google.golang.org/protobuf v1.36.7 modernc.org/sqlite v1.47.0
modernc.org/sqlite v1.46.1
) )
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/webauthn v0.16.1 // indirect
github.com/go-webauthn/x v0.2.2 // indirect github.com/go-webauthn/x v0.2.2 // indirect
github.com/google/go-tpm v0.9.8 // indirect github.com/google/go-tpm v0.9.8 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect github.com/spf13/pflag v1.0.9 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.51.0 // indirect golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.41.0 // indirect
golang.org/x/text v0.35.0 // indirect golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
modernc.org/libc v1.67.6 // indirect modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
) )

82
go.sum
View File

@@ -1,3 +1,8 @@
git.wntrmute.dev/mc/mcdsl v1.4.0 h1:PsEIyskcjBduwHSRwNB/U/uSeU/cv3C8MVr0SRjBRLg=
git.wntrmute.dev/mc/mcdsl v1.4.0/go.mod h1:MhYahIu7Sg53lE2zpQ20nlrsoNRjQzOJBAlCmom2wJc=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -24,46 +29,56 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
@@ -79,28 +94,31 @@ golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -109,8 +127,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -29,7 +29,7 @@ import (
"golang.org/x/crypto/argon2" "golang.org/x/crypto/argon2"
"git.wntrmute.dev/kyle/mcias/internal/crypto" "git.wntrmute.dev/mc/mcias/internal/crypto"
) )
// ErrInvalidCredentials is returned for any authentication failure. // ErrInvalidCredentials is returned for any authentication failure.

View File

@@ -6,7 +6,7 @@ import (
"fmt" "fmt"
"time" "time"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/mc/mcias/internal/model"
"github.com/google/uuid" "github.com/google/uuid"
) )

View File

@@ -5,7 +5,7 @@ import (
"testing" "testing"
"time" "time"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/mc/mcias/internal/model"
) )
// openTestDB opens an in-memory SQLite database for testing. // openTestDB opens an in-memory SQLite database for testing.

View File

@@ -4,7 +4,7 @@ import (
"testing" "testing"
"time" "time"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/mc/mcias/internal/model"
) )
// openTestDB is defined in db_test.go in this package; reused here. // openTestDB is defined in db_test.go in this package; reused here.

View File

@@ -22,7 +22,7 @@ var migrationsFS embed.FS
// LatestSchemaVersion is the highest migration version defined in the // LatestSchemaVersion is the highest migration version defined in the
// migrations/ directory. Update this constant whenever a new migration file // migrations/ directory. Update this constant whenever a new migration file
// is added. // is added.
const LatestSchemaVersion = 9 const LatestSchemaVersion = 10
// newMigrate constructs a migrate.Migrate instance backed by the embedded SQL // newMigrate constructs a migrate.Migrate instance backed by the embedded SQL
// files. It opens a dedicated *sql.DB using the same DSN as the main // files. It opens a dedicated *sql.DB using the same DSN as the main

View File

@@ -0,0 +1,10 @@
CREATE TABLE sso_clients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL UNIQUE,
redirect_uri TEXT NOT NULL,
tags_json TEXT NOT NULL DEFAULT '[]',
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0,1)),
created_by INTEGER REFERENCES accounts(id),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);

View File

@@ -6,7 +6,7 @@ import (
"fmt" "fmt"
"time" "time"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/mc/mcias/internal/model"
) )
// ListCredentialedAccountIDs returns the set of account IDs that already have // ListCredentialedAccountIDs returns the set of account IDs that already have

View File

@@ -6,7 +6,7 @@ import (
"fmt" "fmt"
"time" "time"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/mc/mcias/internal/model"
) )
// policyRuleCols is the column list for all policy rule SELECT queries. // policyRuleCols is the column list for all policy rule SELECT queries.

View File

@@ -5,7 +5,7 @@ import (
"testing" "testing"
"time" "time"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/mc/mcias/internal/model"
) )
func TestCreateAndGetPolicyRule(t *testing.T) { func TestCreateAndGetPolicyRule(t *testing.T) {

68
internal/db/snapshot.go Normal file
View File

@@ -0,0 +1,68 @@
package db
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// Snapshot creates a consistent backup of the database at destPath using
// SQLite's VACUUM INTO statement. VACUUM INTO acquires a read lock for the
// duration of the copy, which is safe while the server is running in WAL mode.
// The destination file is created by SQLite; the caller must ensure the parent
// directory exists.
func (db *DB) Snapshot(destPath string) error {
// VACUUM INTO is not supported on in-memory databases.
if strings.Contains(db.path, "mode=memory") {
return fmt.Errorf("db: snapshot not supported on in-memory databases")
}
if _, err := db.sql.Exec("VACUUM INTO ?", destPath); err != nil {
return fmt.Errorf("db: snapshot VACUUM INTO %q: %w", destPath, err)
}
return nil
}
// SnapshotDir creates a timestamped backup in dir and prunes backups older
// than retainDays days. dir is created with mode 0750 if it does not exist.
// The backup filename format is mcias-20060102-150405.db.
func (db *DB) SnapshotDir(dir string, retainDays int) (string, error) {
if err := os.MkdirAll(dir, 0750); err != nil {
return "", fmt.Errorf("db: create backup dir %q: %w", dir, err)
}
ts := time.Now().UTC().Format("20060102-150405")
dest := filepath.Join(dir, fmt.Sprintf("mcias-%s.db", ts))
if err := db.Snapshot(dest); err != nil {
return "", err
}
// Prune backups older than retainDays.
if retainDays > 0 {
cutoff := time.Now().UTC().AddDate(0, 0, -retainDays)
entries, err := os.ReadDir(dir)
if err != nil {
// Non-fatal: the backup was written; log pruning failure separately.
return dest, fmt.Errorf("db: list backup dir for pruning: %w", err)
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".db") {
continue
}
// Skip the file we just wrote.
if e.Name() == filepath.Base(dest) {
continue
}
info, err := e.Info()
if err != nil {
continue
}
if info.ModTime().Before(cutoff) {
_ = os.Remove(filepath.Join(dir, e.Name()))
}
}
}
return dest, nil
}

206
internal/db/sso_clients.go Normal file
View File

@@ -0,0 +1,206 @@
package db
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"git.wntrmute.dev/mc/mcias/internal/model"
)
const ssoClientCols = `id, client_id, redirect_uri, tags_json, enabled, created_by, created_at, updated_at`
// CreateSSOClient inserts a new SSO client. The client_id must be unique
// and the redirect_uri must start with "https://".
func (db *DB) CreateSSOClient(clientID, redirectURI string, tags []string, createdBy *int64) (*model.SSOClient, error) {
if clientID == "" {
return nil, fmt.Errorf("db: client_id is required")
}
if !strings.HasPrefix(redirectURI, "https://") {
return nil, fmt.Errorf("db: redirect_uri must start with https://")
}
if tags == nil {
tags = []string{}
}
tagsJSON, err := json.Marshal(tags)
if err != nil {
return nil, fmt.Errorf("db: marshal tags: %w", err)
}
n := now()
result, err := db.sql.Exec(`
INSERT INTO sso_clients (client_id, redirect_uri, tags_json, enabled, created_by, created_at, updated_at)
VALUES (?, ?, ?, 1, ?, ?, ?)
`, clientID, redirectURI, string(tagsJSON), createdBy, n, n)
if err != nil {
return nil, fmt.Errorf("db: create SSO client: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return nil, fmt.Errorf("db: create SSO client last insert id: %w", err)
}
createdAt, err := parseTime(n)
if err != nil {
return nil, err
}
return &model.SSOClient{
ID: id,
ClientID: clientID,
RedirectURI: redirectURI,
Tags: tags,
Enabled: true,
CreatedBy: createdBy,
CreatedAt: createdAt,
UpdatedAt: createdAt,
}, nil
}
// GetSSOClient retrieves an SSO client by client_id.
// Returns ErrNotFound if no such client exists.
func (db *DB) GetSSOClient(clientID string) (*model.SSOClient, error) {
return scanSSOClient(db.sql.QueryRow(`
SELECT `+ssoClientCols+`
FROM sso_clients WHERE client_id = ?
`, clientID))
}
// ListSSOClients returns all SSO clients ordered by client_id.
func (db *DB) ListSSOClients() ([]*model.SSOClient, error) {
rows, err := db.sql.Query(`
SELECT ` + ssoClientCols + `
FROM sso_clients ORDER BY client_id ASC
`)
if err != nil {
return nil, fmt.Errorf("db: list SSO clients: %w", err)
}
defer func() { _ = rows.Close() }()
var clients []*model.SSOClient
for rows.Next() {
c, err := scanSSOClientRow(rows)
if err != nil {
return nil, err
}
clients = append(clients, c)
}
return clients, rows.Err()
}
// UpdateSSOClient updates the mutable fields of an SSO client.
// Only non-nil fields are changed.
func (db *DB) UpdateSSOClient(clientID string, redirectURI *string, tags *[]string, enabled *bool) error {
n := now()
setClauses := "updated_at = ?"
args := []interface{}{n}
if redirectURI != nil {
if !strings.HasPrefix(*redirectURI, "https://") {
return fmt.Errorf("db: redirect_uri must start with https://")
}
setClauses += ", redirect_uri = ?"
args = append(args, *redirectURI)
}
if tags != nil {
tagsJSON, err := json.Marshal(*tags)
if err != nil {
return fmt.Errorf("db: marshal tags: %w", err)
}
setClauses += ", tags_json = ?"
args = append(args, string(tagsJSON))
}
if enabled != nil {
enabledInt := 0
if *enabled {
enabledInt = 1
}
setClauses += ", enabled = ?"
args = append(args, enabledInt)
}
args = append(args, clientID)
res, err := db.sql.Exec(`UPDATE sso_clients SET `+setClauses+` WHERE client_id = ?`, args...)
if err != nil {
return fmt.Errorf("db: update SSO client %s: %w", clientID, err)
}
n2, _ := res.RowsAffected()
if n2 == 0 {
return ErrNotFound
}
return nil
}
// DeleteSSOClient removes an SSO client by client_id.
func (db *DB) DeleteSSOClient(clientID string) error {
res, err := db.sql.Exec(`DELETE FROM sso_clients WHERE client_id = ?`, clientID)
if err != nil {
return fmt.Errorf("db: delete SSO client %s: %w", clientID, err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
// scanSSOClient scans a single SSO client from a *sql.Row.
func scanSSOClient(row *sql.Row) (*model.SSOClient, error) {
var c model.SSOClient
var enabledInt int
var tagsJSON, createdAtStr, updatedAtStr string
var createdBy *int64
err := row.Scan(&c.ID, &c.ClientID, &c.RedirectURI, &tagsJSON,
&enabledInt, &createdBy, &createdAtStr, &updatedAtStr)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("db: scan SSO client: %w", err)
}
return finishSSOClientScan(&c, enabledInt, createdBy, tagsJSON, createdAtStr, updatedAtStr)
}
// scanSSOClientRow scans a single SSO client from *sql.Rows.
func scanSSOClientRow(rows *sql.Rows) (*model.SSOClient, error) {
var c model.SSOClient
var enabledInt int
var tagsJSON, createdAtStr, updatedAtStr string
var createdBy *int64
err := rows.Scan(&c.ID, &c.ClientID, &c.RedirectURI, &tagsJSON,
&enabledInt, &createdBy, &createdAtStr, &updatedAtStr)
if err != nil {
return nil, fmt.Errorf("db: scan SSO client row: %w", err)
}
return finishSSOClientScan(&c, enabledInt, createdBy, tagsJSON, createdAtStr, updatedAtStr)
}
func finishSSOClientScan(c *model.SSOClient, enabledInt int, createdBy *int64, tagsJSON, createdAtStr, updatedAtStr string) (*model.SSOClient, error) {
c.Enabled = enabledInt == 1
c.CreatedBy = createdBy
var err error
if c.CreatedAt, err = parseTime(createdAtStr); err != nil {
return nil, err
}
if c.UpdatedAt, err = parseTime(updatedAtStr); err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(tagsJSON), &c.Tags); err != nil {
return nil, fmt.Errorf("db: unmarshal SSO client tags: %w", err)
}
if c.Tags == nil {
c.Tags = []string{}
}
return c, nil
}

View File

@@ -0,0 +1,192 @@
package db
import (
"errors"
"testing"
)
func TestCreateAndGetSSOClient(t *testing.T) {
db := openTestDB(t)
c, err := db.CreateSSOClient("mcr", "https://mcr.example.com/sso/callback", []string{"env:prod"}, nil)
if err != nil {
t.Fatalf("CreateSSOClient: %v", err)
}
if c.ID == 0 {
t.Error("expected non-zero ID")
}
if c.ClientID != "mcr" {
t.Errorf("client_id = %q, want %q", c.ClientID, "mcr")
}
if !c.Enabled {
t.Error("new client should be enabled by default")
}
if len(c.Tags) != 1 || c.Tags[0] != "env:prod" {
t.Errorf("tags = %v, want [env:prod]", c.Tags)
}
got, err := db.GetSSOClient("mcr")
if err != nil {
t.Fatalf("GetSSOClient: %v", err)
}
if got.RedirectURI != "https://mcr.example.com/sso/callback" {
t.Errorf("redirect_uri = %q", got.RedirectURI)
}
if len(got.Tags) != 1 || got.Tags[0] != "env:prod" {
t.Errorf("tags = %v after round-trip", got.Tags)
}
}
func TestCreateSSOClient_DuplicateClientID(t *testing.T) {
db := openTestDB(t)
_, err := db.CreateSSOClient("mcr", "https://mcr.example.com/cb", nil, nil)
if err != nil {
t.Fatalf("first create: %v", err)
}
_, err = db.CreateSSOClient("mcr", "https://other.example.com/cb", nil, nil)
if err == nil {
t.Error("expected error for duplicate client_id")
}
}
func TestCreateSSOClient_Validation(t *testing.T) {
db := openTestDB(t)
_, err := db.CreateSSOClient("", "https://example.com/cb", nil, nil)
if err == nil {
t.Error("expected error for empty client_id")
}
_, err = db.CreateSSOClient("mcr", "http://example.com/cb", nil, nil)
if err == nil {
t.Error("expected error for non-https redirect_uri")
}
}
func TestGetSSOClient_NotFound(t *testing.T) {
db := openTestDB(t)
_, err := db.GetSSOClient("nonexistent")
if !errors.Is(err, ErrNotFound) {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
func TestListSSOClients(t *testing.T) {
db := openTestDB(t)
clients, err := db.ListSSOClients()
if err != nil {
t.Fatalf("ListSSOClients (empty): %v", err)
}
if len(clients) != 0 {
t.Errorf("expected 0 clients, got %d", len(clients))
}
_, _ = db.CreateSSOClient("mcat", "https://mcat.example.com/cb", nil, nil)
_, _ = db.CreateSSOClient("mcr", "https://mcr.example.com/cb", nil, nil)
clients, err = db.ListSSOClients()
if err != nil {
t.Fatalf("ListSSOClients: %v", err)
}
if len(clients) != 2 {
t.Fatalf("expected 2 clients, got %d", len(clients))
}
// Ordered by client_id ASC.
if clients[0].ClientID != "mcat" {
t.Errorf("first client = %q, want %q", clients[0].ClientID, "mcat")
}
}
func TestUpdateSSOClient(t *testing.T) {
db := openTestDB(t)
_, err := db.CreateSSOClient("mcr", "https://mcr.example.com/cb", []string{"a"}, nil)
if err != nil {
t.Fatalf("create: %v", err)
}
newURI := "https://mcr.example.com/sso/callback"
newTags := []string{"b", "c"}
disabled := false
if err := db.UpdateSSOClient("mcr", &newURI, &newTags, &disabled); err != nil {
t.Fatalf("UpdateSSOClient: %v", err)
}
got, err := db.GetSSOClient("mcr")
if err != nil {
t.Fatalf("get after update: %v", err)
}
if got.RedirectURI != newURI {
t.Errorf("redirect_uri = %q, want %q", got.RedirectURI, newURI)
}
if len(got.Tags) != 2 || got.Tags[0] != "b" {
t.Errorf("tags = %v, want [b c]", got.Tags)
}
if got.Enabled {
t.Error("expected enabled=false after update")
}
}
func TestUpdateSSOClient_NotFound(t *testing.T) {
db := openTestDB(t)
uri := "https://x.example.com/cb"
err := db.UpdateSSOClient("nonexistent", &uri, nil, nil)
if !errors.Is(err, ErrNotFound) {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
func TestDeleteSSOClient(t *testing.T) {
db := openTestDB(t)
_, err := db.CreateSSOClient("mcr", "https://mcr.example.com/cb", nil, nil)
if err != nil {
t.Fatalf("create: %v", err)
}
if err := db.DeleteSSOClient("mcr"); err != nil {
t.Fatalf("DeleteSSOClient: %v", err)
}
_, err = db.GetSSOClient("mcr")
if !errors.Is(err, ErrNotFound) {
t.Errorf("expected ErrNotFound after delete, got %v", err)
}
}
func TestDeleteSSOClient_NotFound(t *testing.T) {
db := openTestDB(t)
err := db.DeleteSSOClient("nonexistent")
if !errors.Is(err, ErrNotFound) {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
func TestCreateSSOClient_NilTags(t *testing.T) {
db := openTestDB(t)
c, err := db.CreateSSOClient("mcr", "https://mcr.example.com/cb", nil, nil)
if err != nil {
t.Fatalf("create: %v", err)
}
if c.Tags == nil {
t.Error("Tags should be empty slice, not nil")
}
if len(c.Tags) != 0 {
t.Errorf("expected 0 tags, got %d", len(c.Tags))
}
got, err := db.GetSSOClient("mcr")
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Tags == nil || len(got.Tags) != 0 {
t.Errorf("Tags round-trip: got %v", got.Tags)
}
}

View File

@@ -3,7 +3,7 @@ package db
import ( import (
"testing" "testing"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/mc/mcias/internal/model"
) )
func TestGetAccountTags_Empty(t *testing.T) { func TestGetAccountTags_Empty(t *testing.T) {

View File

@@ -5,7 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/mc/mcias/internal/model"
) )
// CreateWebAuthnCredential inserts a new WebAuthn credential record. // CreateWebAuthnCredential inserts a new WebAuthn credential record.

View File

@@ -4,7 +4,7 @@ import (
"errors" "errors"
"testing" "testing"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/mc/mcias/internal/model"
) )
func TestWebAuthnCRUD(t *testing.T) { func TestWebAuthnCRUD(t *testing.T) {

View File

@@ -11,11 +11,11 @@ import (
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1" mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
"git.wntrmute.dev/kyle/mcias/internal/auth" "git.wntrmute.dev/mc/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/validate" "git.wntrmute.dev/mc/mcias/internal/validate"
) )
type accountServiceServer struct { type accountServiceServer struct {

View File

@@ -9,7 +9,7 @@ import (
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1" mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
) )
type adminServiceServer struct { type adminServiceServer struct {

View File

@@ -13,12 +13,12 @@ import (
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1" mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
"git.wntrmute.dev/kyle/mcias/internal/audit" "git.wntrmute.dev/mc/mcias/internal/audit"
"git.wntrmute.dev/kyle/mcias/internal/auth" "git.wntrmute.dev/mc/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/crypto" "git.wntrmute.dev/mc/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/token" "git.wntrmute.dev/mc/mcias/internal/token"
) )
type authServiceServer struct { type authServiceServer struct {

View File

@@ -9,10 +9,10 @@ import (
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1" mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
"git.wntrmute.dev/kyle/mcias/internal/crypto" "git.wntrmute.dev/mc/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/mc/mcias/internal/model"
) )
type credentialServiceServer struct { type credentialServiceServer struct {

View File

@@ -30,11 +30,11 @@ import (
"google.golang.org/grpc/peer" "google.golang.org/grpc/peer"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1" mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
"git.wntrmute.dev/kyle/mcias/internal/config" "git.wntrmute.dev/mc/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/token" "git.wntrmute.dev/mc/mcias/internal/token"
"git.wntrmute.dev/kyle/mcias/internal/vault" "git.wntrmute.dev/mc/mcias/internal/vault"
) )
// contextKey is the unexported context key type for this package. // contextKey is the unexported context key type for this package.
@@ -118,6 +118,7 @@ func (s *Server) buildServer(extra ...grpc.ServerOption) *grpc.Server {
mciasv1.RegisterAccountServiceServer(srv, &accountServiceServer{s: s}) mciasv1.RegisterAccountServiceServer(srv, &accountServiceServer{s: s})
mciasv1.RegisterCredentialServiceServer(srv, &credentialServiceServer{s: s}) mciasv1.RegisterCredentialServiceServer(srv, &credentialServiceServer{s: s})
mciasv1.RegisterPolicyServiceServer(srv, &policyServiceServer{s: s}) mciasv1.RegisterPolicyServiceServer(srv, &policyServiceServer{s: s})
mciasv1.RegisterSSOClientServiceServer(srv, &ssoClientServiceServer{s: s})
return srv return srv
} }

View File

@@ -24,13 +24,13 @@ import (
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"google.golang.org/grpc/test/bufconn" "google.golang.org/grpc/test/bufconn"
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1" mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
"git.wntrmute.dev/kyle/mcias/internal/auth" "git.wntrmute.dev/mc/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/config" "git.wntrmute.dev/mc/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/token" "git.wntrmute.dev/mc/mcias/internal/token"
"git.wntrmute.dev/kyle/mcias/internal/vault" "git.wntrmute.dev/mc/mcias/internal/vault"
) )
const ( const (

View File

@@ -13,10 +13,10 @@ import (
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1" mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
"git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/policy" "git.wntrmute.dev/mc/mcias/internal/policy"
) )
type policyServiceServer struct { type policyServiceServer struct {

View File

@@ -0,0 +1,187 @@
// ssoclientservice implements mciasv1.SSOClientServiceServer.
// All handlers are admin-only and delegate to the same db package used by
// the REST SSO client handlers in internal/server/handlers_sso_clients.go.
package grpcserver
import (
"context"
"errors"
"fmt"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
"git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/model"
)
type ssoClientServiceServer struct {
mciasv1.UnimplementedSSOClientServiceServer
s *Server
}
func ssoClientToProto(c *model.SSOClient) *mciasv1.SSOClient {
return &mciasv1.SSOClient{
ClientId: c.ClientID,
RedirectUri: c.RedirectURI,
Tags: c.Tags,
Enabled: c.Enabled,
CreatedAt: c.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: c.UpdatedAt.UTC().Format(time.RFC3339),
}
}
func (ss *ssoClientServiceServer) ListSSOClients(ctx context.Context, _ *mciasv1.ListSSOClientsRequest) (*mciasv1.ListSSOClientsResponse, error) {
if err := ss.s.requireAdmin(ctx); err != nil {
return nil, err
}
clients, err := ss.s.db.ListSSOClients()
if err != nil {
ss.s.logger.Error("list SSO clients", "error", err)
return nil, status.Error(codes.Internal, "internal error")
}
resp := &mciasv1.ListSSOClientsResponse{
Clients: make([]*mciasv1.SSOClient, 0, len(clients)),
}
for _, c := range clients {
resp.Clients = append(resp.Clients, ssoClientToProto(c))
}
return resp, nil
}
func (ss *ssoClientServiceServer) CreateSSOClient(ctx context.Context, req *mciasv1.CreateSSOClientRequest) (*mciasv1.CreateSSOClientResponse, error) {
if err := ss.s.requireAdmin(ctx); err != nil {
return nil, err
}
if req.ClientId == "" {
return nil, status.Error(codes.InvalidArgument, "client_id is required")
}
if req.RedirectUri == "" {
return nil, status.Error(codes.InvalidArgument, "redirect_uri is required")
}
claims := claimsFromContext(ctx)
var createdBy *int64
if claims != nil {
if actor, err := ss.s.db.GetAccountByUUID(claims.Subject); err == nil {
createdBy = &actor.ID
}
}
c, err := ss.s.db.CreateSSOClient(req.ClientId, req.RedirectUri, req.Tags, createdBy)
if err != nil {
ss.s.logger.Error("create SSO client", "error", err)
return nil, status.Error(codes.InvalidArgument, err.Error())
}
ss.s.db.WriteAuditEvent(model.EventSSOClientCreated, createdBy, nil, peerIP(ctx), //nolint:errcheck
fmt.Sprintf(`{"client_id":%q}`, c.ClientID))
return &mciasv1.CreateSSOClientResponse{Client: ssoClientToProto(c)}, nil
}
func (ss *ssoClientServiceServer) GetSSOClient(ctx context.Context, req *mciasv1.GetSSOClientRequest) (*mciasv1.GetSSOClientResponse, error) {
if err := ss.s.requireAdmin(ctx); err != nil {
return nil, err
}
if req.ClientId == "" {
return nil, status.Error(codes.InvalidArgument, "client_id is required")
}
c, err := ss.s.db.GetSSOClient(req.ClientId)
if errors.Is(err, db.ErrNotFound) {
return nil, status.Error(codes.NotFound, "SSO client not found")
}
if err != nil {
ss.s.logger.Error("get SSO client", "error", err)
return nil, status.Error(codes.Internal, "internal error")
}
return &mciasv1.GetSSOClientResponse{Client: ssoClientToProto(c)}, nil
}
func (ss *ssoClientServiceServer) UpdateSSOClient(ctx context.Context, req *mciasv1.UpdateSSOClientRequest) (*mciasv1.UpdateSSOClientResponse, error) {
if err := ss.s.requireAdmin(ctx); err != nil {
return nil, err
}
if req.ClientId == "" {
return nil, status.Error(codes.InvalidArgument, "client_id is required")
}
var redirectURI *string
if req.RedirectUri != nil {
v := req.GetRedirectUri()
redirectURI = &v
}
var tags *[]string
if req.UpdateTags {
t := req.Tags
tags = &t
}
var enabled *bool
if req.Enabled != nil {
v := req.GetEnabled()
enabled = &v
}
err := ss.s.db.UpdateSSOClient(req.ClientId, redirectURI, tags, enabled)
if errors.Is(err, db.ErrNotFound) {
return nil, status.Error(codes.NotFound, "SSO client not found")
}
if err != nil {
ss.s.logger.Error("update SSO client", "error", err)
return nil, status.Error(codes.InvalidArgument, err.Error())
}
claims := claimsFromContext(ctx)
var actorID *int64
if claims != nil {
if actor, err := ss.s.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
ss.s.db.WriteAuditEvent(model.EventSSOClientUpdated, actorID, nil, peerIP(ctx), //nolint:errcheck
fmt.Sprintf(`{"client_id":%q}`, req.ClientId))
updated, err := ss.s.db.GetSSOClient(req.ClientId)
if err != nil {
return nil, status.Error(codes.Internal, "internal error")
}
return &mciasv1.UpdateSSOClientResponse{Client: ssoClientToProto(updated)}, nil
}
func (ss *ssoClientServiceServer) DeleteSSOClient(ctx context.Context, req *mciasv1.DeleteSSOClientRequest) (*mciasv1.DeleteSSOClientResponse, error) {
if err := ss.s.requireAdmin(ctx); err != nil {
return nil, err
}
if req.ClientId == "" {
return nil, status.Error(codes.InvalidArgument, "client_id is required")
}
err := ss.s.db.DeleteSSOClient(req.ClientId)
if errors.Is(err, db.ErrNotFound) {
return nil, status.Error(codes.NotFound, "SSO client not found")
}
if err != nil {
ss.s.logger.Error("delete SSO client", "error", err)
return nil, status.Error(codes.Internal, "internal error")
}
claims := claimsFromContext(ctx)
var actorID *int64
if claims != nil {
if actor, err := ss.s.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
ss.s.db.WriteAuditEvent(model.EventSSOClientDeleted, actorID, nil, peerIP(ctx), //nolint:errcheck
fmt.Sprintf(`{"client_id":%q}`, req.ClientId))
return &mciasv1.DeleteSSOClientResponse{}, nil
}

View File

@@ -10,10 +10,10 @@ import (
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1" mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
"git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/token" "git.wntrmute.dev/mc/mcias/internal/token"
) )
type tokenServiceServer struct { type tokenServiceServer struct {

View File

@@ -11,8 +11,8 @@ import (
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1" mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/mc/mcias/internal/model"
) )
// ListWebAuthnCredentials returns metadata for an account's WebAuthn credentials. // ListWebAuthnCredentials returns metadata for an account's WebAuthn credentials.

View File

@@ -23,10 +23,10 @@ import (
"sync" "sync"
"time" "time"
"git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/policy" "git.wntrmute.dev/mc/mcias/internal/policy"
"git.wntrmute.dev/kyle/mcias/internal/token" "git.wntrmute.dev/mc/mcias/internal/token"
"git.wntrmute.dev/kyle/mcias/internal/vault" "git.wntrmute.dev/mc/mcias/internal/vault"
) )
// contextKey is the unexported type for context keys in this package, preventing // contextKey is the unexported type for context keys in this package, preventing

View File

@@ -12,10 +12,10 @@ import (
"testing" "testing"
"time" "time"
"git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/token" "git.wntrmute.dev/mc/mcias/internal/token"
"git.wntrmute.dev/kyle/mcias/internal/vault" "git.wntrmute.dev/mc/mcias/internal/vault"
) )
func generateTestKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) { func generateTestKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {

View File

@@ -218,8 +218,29 @@ const (
EventWebAuthnRemoved = "webauthn_removed" EventWebAuthnRemoved = "webauthn_removed"
EventWebAuthnLoginOK = "webauthn_login_ok" EventWebAuthnLoginOK = "webauthn_login_ok"
EventWebAuthnLoginFail = "webauthn_login_fail" EventWebAuthnLoginFail = "webauthn_login_fail"
EventSSOAuthorize = "sso_authorize"
EventSSOLoginOK = "sso_login_ok"
EventSSOClientCreated = "sso_client_created"
EventSSOClientUpdated = "sso_client_updated"
EventSSOClientDeleted = "sso_client_deleted"
) )
// SSOClient represents a registered relying-party application that may use
// the MCIAS SSO authorization code flow. The ClientID serves as both the
// unique identifier and the service_name for policy evaluation.
type SSOClient struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedBy *int64 `json:"-"`
ClientID string `json:"client_id"`
RedirectURI string `json:"redirect_uri"`
Tags []string `json:"tags"`
ID int64 `json:"-"`
Enabled bool `json:"enabled"`
}
// ServiceAccountDelegate records that a specific account has been granted // ServiceAccountDelegate records that a specific account has been granted
// permission to issue tokens for a given system account. Only admins can // permission to issue tokens for a given system account. Only admins can
// add or remove delegates; delegates can issue/rotate tokens for that specific // add or remove delegates; delegates can issue/rotate tokens for that specific

View File

@@ -51,6 +51,8 @@ const (
ActionEnrollWebAuthn Action = "webauthn:enroll" // self-service ActionEnrollWebAuthn Action = "webauthn:enroll" // self-service
ActionRemoveWebAuthn Action = "webauthn:remove" // admin ActionRemoveWebAuthn Action = "webauthn:remove" // admin
ActionManageSSOClients Action = "sso_clients:manage" // admin
) )
// ResourceType identifies what kind of object a request targets. // ResourceType identifies what kind of object a request targets.
@@ -62,8 +64,9 @@ const (
ResourcePGCreds ResourceType = "pgcreds" ResourcePGCreds ResourceType = "pgcreds"
ResourceAuditLog ResourceType = "audit_log" ResourceAuditLog ResourceType = "audit_log"
ResourceTOTP ResourceType = "totp" ResourceTOTP ResourceType = "totp"
ResourcePolicy ResourceType = "policy" ResourcePolicy ResourceType = "policy"
ResourceWebAuthn ResourceType = "webauthn" ResourceWebAuthn ResourceType = "webauthn"
ResourceSSOClient ResourceType = "sso_client"
) )
// Effect is the outcome of policy evaluation. // Effect is the outcome of policy evaluation.

View File

@@ -8,10 +8,10 @@ import (
"strconv" "strconv"
"time" "time"
"git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/middleware" "git.wntrmute.dev/mc/mcias/internal/middleware"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/policy" "git.wntrmute.dev/mc/mcias/internal/policy"
) )
// ---- Tag endpoints ---- // ---- Tag endpoints ----

View File

@@ -0,0 +1,145 @@
package server
import (
"net/http"
"git.wntrmute.dev/mc/mcias/internal/audit"
"git.wntrmute.dev/mc/mcias/internal/middleware"
"git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/policy"
"git.wntrmute.dev/mc/mcias/internal/sso"
"git.wntrmute.dev/mc/mcias/internal/token"
)
// ssoTokenRequest is the request body for POST /v1/sso/token.
type ssoTokenRequest struct {
Code string `json:"code"`
ClientID string `json:"client_id"`
RedirectURI string `json:"redirect_uri"`
}
// handleSSOTokenExchange exchanges an SSO authorization code for a JWT token.
//
// Security design:
// - The authorization code is single-use (consumed via LoadAndDelete).
// - The client_id and redirect_uri must match the values stored when the code
// was issued, preventing a stolen code from being exchanged by a different
// service.
// - Policy evaluation uses the service_name and tags from the registered SSO
// client config (not from the request), preventing identity spoofing.
// - The code expires after 60 seconds to limit the interception window.
func (s *Server) handleSSOTokenExchange(w http.ResponseWriter, r *http.Request) {
var req ssoTokenRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Code == "" || req.ClientID == "" || req.RedirectURI == "" {
middleware.WriteError(w, http.StatusBadRequest, "code, client_id, and redirect_uri are required", "bad_request")
return
}
// Consume the authorization code (single-use).
ac, ok := sso.Consume(req.Code)
if !ok {
middleware.WriteError(w, http.StatusUnauthorized, "invalid or expired authorization code", "invalid_code")
return
}
// Security: verify client_id and redirect_uri match the stored values.
if ac.ClientID != req.ClientID || ac.RedirectURI != req.RedirectURI {
s.logger.Warn("sso: token exchange parameter mismatch",
"expected_client", ac.ClientID, "got_client", req.ClientID)
middleware.WriteError(w, http.StatusUnauthorized, "invalid or expired authorization code", "invalid_code")
return
}
// Look up the registered SSO client from the database for policy context.
client, clientErr := s.db.GetSSOClient(req.ClientID)
if clientErr != nil {
// Should not happen if the authorize endpoint validated, but defend in depth.
middleware.WriteError(w, http.StatusUnauthorized, "unknown client", "invalid_code")
return
}
if !client.Enabled {
middleware.WriteError(w, http.StatusForbidden, "SSO client is disabled", "client_disabled")
return
}
// Load account.
acct, err := s.db.GetAccountByID(ac.AccountID)
if err != nil {
s.logger.Error("sso: load account for token exchange", "error", err, "account_id", ac.AccountID)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
if acct.Status != model.AccountStatusActive {
middleware.WriteError(w, http.StatusForbidden, "account is not active", "account_inactive")
return
}
// Load roles for policy evaluation and expiry decision.
roles, err := s.db.GetRoles(acct.ID)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
// Policy evaluation: client_id serves as both identifier and service_name.
{
input := policy.PolicyInput{
Subject: acct.UUID,
AccountType: string(acct.AccountType),
Roles: roles,
Action: policy.ActionLogin,
Resource: policy.Resource{
ServiceName: client.ClientID,
Tags: client.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", client.ClientID, "via", "sso"))
middleware.WriteError(w, http.StatusForbidden, "access denied by policy", "policy_denied")
return
}
}
// Determine expiry.
expiry := s.cfg.DefaultExpiry()
for _, rol := range roles {
if rol == "admin" {
expiry = s.cfg.AdminExpiry()
break
}
}
privKey, err := s.vault.PrivKey()
if err != nil {
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
return
}
tokenStr, claims, err := token.IssueToken(privKey, s.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
if err != nil {
s.logger.Error("sso: issue token", "error", err)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
if err := s.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
s.logger.Error("sso: track token", "error", err)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
s.writeAudit(r, model.EventSSOLoginOK, &acct.ID, nil,
audit.JSON("jti", claims.JTI, "client_id", client.ClientID))
s.writeAudit(r, model.EventTokenIssued, &acct.ID, nil,
audit.JSON("jti", claims.JTI, "via", "sso"))
writeJSON(w, http.StatusOK, loginResponse{
Token: tokenStr,
ExpiresAt: claims.ExpiresAt.Format("2006-01-02T15:04:05Z"),
})
}

View File

@@ -0,0 +1,175 @@
package server
import (
"errors"
"fmt"
"net/http"
"time"
"git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/middleware"
"git.wntrmute.dev/mc/mcias/internal/model"
)
type ssoClientResponse struct {
ClientID string `json:"client_id"`
RedirectURI string `json:"redirect_uri"`
Tags []string `json:"tags"`
Enabled bool `json:"enabled"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func ssoClientToResponse(c *model.SSOClient) ssoClientResponse {
return ssoClientResponse{
ClientID: c.ClientID,
RedirectURI: c.RedirectURI,
Tags: c.Tags,
Enabled: c.Enabled,
CreatedAt: c.CreatedAt.Format(time.RFC3339),
UpdatedAt: c.UpdatedAt.Format(time.RFC3339),
}
}
func (s *Server) handleListSSOClients(w http.ResponseWriter, r *http.Request) {
clients, err := s.db.ListSSOClients()
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
resp := make([]ssoClientResponse, 0, len(clients))
for _, c := range clients {
resp = append(resp, ssoClientToResponse(c))
}
writeJSON(w, http.StatusOK, resp)
}
type createSSOClientRequest struct {
ClientID string `json:"client_id"`
RedirectURI string `json:"redirect_uri"`
Tags []string `json:"tags"`
}
func (s *Server) handleCreateSSOClient(w http.ResponseWriter, r *http.Request) {
var req createSSOClientRequest
if !decodeJSON(w, r, &req) {
return
}
if req.ClientID == "" {
middleware.WriteError(w, http.StatusBadRequest, "client_id is required", "bad_request")
return
}
if req.RedirectURI == "" {
middleware.WriteError(w, http.StatusBadRequest, "redirect_uri is required", "bad_request")
return
}
claims := middleware.ClaimsFromContext(r.Context())
var createdBy *int64
if claims != nil {
if actor, err := s.db.GetAccountByUUID(claims.Subject); err == nil {
createdBy = &actor.ID
}
}
c, err := s.db.CreateSSOClient(req.ClientID, req.RedirectURI, req.Tags, createdBy)
if err != nil {
s.logger.Error("create SSO client", "error", err)
middleware.WriteError(w, http.StatusBadRequest, err.Error(), "bad_request")
return
}
s.writeAudit(r, model.EventSSOClientCreated, createdBy, nil,
fmt.Sprintf(`{"client_id":%q}`, c.ClientID))
writeJSON(w, http.StatusCreated, ssoClientToResponse(c))
}
func (s *Server) handleGetSSOClient(w http.ResponseWriter, r *http.Request) {
clientID := r.PathValue("clientId")
c, err := s.db.GetSSOClient(clientID)
if errors.Is(err, db.ErrNotFound) {
middleware.WriteError(w, http.StatusNotFound, "SSO client not found", "not_found")
return
}
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
writeJSON(w, http.StatusOK, ssoClientToResponse(c))
}
type updateSSOClientRequest struct {
RedirectURI *string `json:"redirect_uri,omitempty"`
Tags *[]string `json:"tags,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
func (s *Server) handleUpdateSSOClient(w http.ResponseWriter, r *http.Request) {
clientID := r.PathValue("clientId")
var req updateSSOClientRequest
if !decodeJSON(w, r, &req) {
return
}
err := s.db.UpdateSSOClient(clientID, req.RedirectURI, req.Tags, req.Enabled)
if errors.Is(err, db.ErrNotFound) {
middleware.WriteError(w, http.StatusNotFound, "SSO client not found", "not_found")
return
}
if err != nil {
s.logger.Error("update SSO client", "error", err)
middleware.WriteError(w, http.StatusBadRequest, err.Error(), "bad_request")
return
}
claims := middleware.ClaimsFromContext(r.Context())
var actorID *int64
if claims != nil {
if actor, err := s.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
s.writeAudit(r, model.EventSSOClientUpdated, actorID, nil,
fmt.Sprintf(`{"client_id":%q}`, clientID))
c, err := s.db.GetSSOClient(clientID)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
writeJSON(w, http.StatusOK, ssoClientToResponse(c))
}
func (s *Server) handleDeleteSSOClient(w http.ResponseWriter, r *http.Request) {
clientID := r.PathValue("clientId")
err := s.db.DeleteSSOClient(clientID)
if errors.Is(err, db.ErrNotFound) {
middleware.WriteError(w, http.StatusNotFound, "SSO client not found", "not_found")
return
}
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
claims := middleware.ClaimsFromContext(r.Context())
var actorID *int64
if claims != nil {
if actor, err := s.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
s.writeAudit(r, model.EventSSOClientDeleted, actorID, nil,
fmt.Sprintf(`{"client_id":%q}`, clientID))
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -23,13 +23,14 @@ import (
"github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/protocol"
libwebauthn "github.com/go-webauthn/webauthn/webauthn" libwebauthn "github.com/go-webauthn/webauthn/webauthn"
"git.wntrmute.dev/kyle/mcias/internal/audit" "git.wntrmute.dev/mc/mcias/internal/audit"
"git.wntrmute.dev/kyle/mcias/internal/auth" "git.wntrmute.dev/mc/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/crypto" "git.wntrmute.dev/mc/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/middleware" "git.wntrmute.dev/mc/mcias/internal/middleware"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/token" "git.wntrmute.dev/mc/mcias/internal/policy"
mciaswebauthn "git.wntrmute.dev/kyle/mcias/internal/webauthn" "git.wntrmute.dev/mc/mcias/internal/token"
mciaswebauthn "git.wntrmute.dev/mc/mcias/internal/webauthn"
) )
const ( const (
@@ -618,13 +619,37 @@ func (s *Server) handleWebAuthnLoginFinish(w http.ResponseWriter, r *http.Reques
// Login succeeded: clear lockout counter. // Login succeeded: clear lockout counter.
_ = s.db.ClearLoginFailures(acct.ID) _ = s.db.ClearLoginFailures(acct.ID)
// Issue JWT. // Load roles for policy check and expiry decision.
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 rules.
// WebAuthn login has no service context (no service_name or tags in the
// request body), so per-service deny rules won't fire. Account-level deny
// rules (e.g. deny a specific role from all auth:login actions) apply.
// This mirrors the policy gate in handleLogin so both auth paths are consistent.
//
// Security: policy is checked after credential verification so that a
// policy-denied login returns 403 (not 401), distinguishing a policy
// restriction from a bad credential without leaking account existence.
if s.polEng != nil {
input := policy.PolicyInput{
Subject: acct.UUID,
AccountType: string(acct.AccountType),
Roles: roles,
Action: policy.ActionLogin,
Resource: policy.Resource{},
}
if effect, _ := s.polEng.Evaluate(input); effect == policy.Deny {
s.writeAudit(r, model.EventWebAuthnLoginFail, &acct.ID, nil, `{"reason":"policy_denied"}`)
middleware.WriteError(w, http.StatusForbidden, "access denied by policy", "policy_denied")
return
}
}
expiry := s.cfg.DefaultExpiry() expiry := s.cfg.DefaultExpiry()
for _, role := range roles { for _, role := range roles {
if role == "admin" { if role == "admin" {

View File

@@ -21,19 +21,19 @@ import (
"strings" "strings"
"time" "time"
"git.wntrmute.dev/kyle/mcias/internal/audit" "git.wntrmute.dev/mc/mcias/internal/audit"
"git.wntrmute.dev/kyle/mcias/internal/auth" "git.wntrmute.dev/mc/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/config" "git.wntrmute.dev/mc/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/crypto" "git.wntrmute.dev/mc/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/middleware" "git.wntrmute.dev/mc/mcias/internal/middleware"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/policy" "git.wntrmute.dev/mc/mcias/internal/policy"
"git.wntrmute.dev/kyle/mcias/internal/token" "git.wntrmute.dev/mc/mcias/internal/token"
"git.wntrmute.dev/kyle/mcias/internal/ui" "git.wntrmute.dev/mc/mcias/internal/ui"
"git.wntrmute.dev/kyle/mcias/internal/validate" "git.wntrmute.dev/mc/mcias/internal/validate"
"git.wntrmute.dev/kyle/mcias/internal/vault" "git.wntrmute.dev/mc/mcias/internal/vault"
"git.wntrmute.dev/kyle/mcias/web" "git.wntrmute.dev/mc/mcias/web"
) )
// Server holds the dependencies injected into all handlers. // Server holds the dependencies injected into all handlers.
@@ -215,6 +215,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /v1/health", s.handleHealth) mux.HandleFunc("GET /v1/health", s.handleHealth)
mux.HandleFunc("GET /v1/keys/public", s.handlePublicKey) mux.HandleFunc("GET /v1/keys/public", s.handlePublicKey)
mux.Handle("POST /v1/auth/login", loginRateLimit(http.HandlerFunc(s.handleLogin))) mux.Handle("POST /v1/auth/login", loginRateLimit(http.HandlerFunc(s.handleLogin)))
mux.Handle("POST /v1/sso/token", loginRateLimit(http.HandlerFunc(s.handleSSOTokenExchange)))
mux.Handle("POST /v1/token/validate", loginRateLimit(http.HandlerFunc(s.handleTokenValidate))) mux.Handle("POST /v1/token/validate", loginRateLimit(http.HandlerFunc(s.handleTokenValidate)))
// API documentation: Swagger UI at /docs and raw spec at /docs/openapi.yaml. // API documentation: Swagger UI at /docs and raw spec at /docs/openapi.yaml.
@@ -372,6 +373,18 @@ func (s *Server) Handler() http.Handler {
mux.Handle("DELETE /v1/policy/rules/{id}", mux.Handle("DELETE /v1/policy/rules/{id}",
requirePolicy(policy.ActionManageRules, policy.ResourcePolicy, nil)(http.HandlerFunc(s.handleDeletePolicyRule))) requirePolicy(policy.ActionManageRules, policy.ResourcePolicy, nil)(http.HandlerFunc(s.handleDeletePolicyRule)))
// SSO client management (admin-only).
mux.Handle("GET /v1/sso/clients",
requirePolicy(policy.ActionManageSSOClients, policy.ResourceSSOClient, nil)(http.HandlerFunc(s.handleListSSOClients)))
mux.Handle("POST /v1/sso/clients",
requirePolicy(policy.ActionManageSSOClients, policy.ResourceSSOClient, nil)(http.HandlerFunc(s.handleCreateSSOClient)))
mux.Handle("GET /v1/sso/clients/{clientId}",
requirePolicy(policy.ActionManageSSOClients, policy.ResourceSSOClient, nil)(http.HandlerFunc(s.handleGetSSOClient)))
mux.Handle("PATCH /v1/sso/clients/{clientId}",
requirePolicy(policy.ActionManageSSOClients, policy.ResourceSSOClient, nil)(http.HandlerFunc(s.handleUpdateSSOClient)))
mux.Handle("DELETE /v1/sso/clients/{clientId}",
requirePolicy(policy.ActionManageSSOClients, policy.ResourceSSOClient, nil)(http.HandlerFunc(s.handleDeleteSSOClient)))
// UI routes (HTMX-based management frontend). // UI routes (HTMX-based management frontend).
uiSrv, err := ui.New(s.db, s.cfg, s.vault, s.logger) uiSrv, err := ui.New(s.db, s.cfg, s.vault, s.logger)
if err != nil { if err != nil {
@@ -436,6 +449,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 +565,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()
@@ -669,11 +717,12 @@ type validateRequest struct {
} }
type validateResponse struct { type validateResponse struct {
Subject string `json:"sub,omitempty"` Subject string `json:"sub,omitempty"`
Username string `json:"username,omitempty"` Username string `json:"username,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"` AccountType string `json:"account_type,omitempty"`
Roles []string `json:"roles,omitempty"` ExpiresAt string `json:"expires_at,omitempty"`
Valid bool `json:"valid"` Roles []string `json:"roles,omitempty"`
Valid bool `json:"valid"`
} }
func (s *Server) handleTokenValidate(w http.ResponseWriter, r *http.Request) { func (s *Server) handleTokenValidate(w http.ResponseWriter, r *http.Request) {
@@ -718,6 +767,7 @@ func (s *Server) handleTokenValidate(w http.ResponseWriter, r *http.Request) {
} }
if acct, err := s.db.GetAccountByUUID(claims.Subject); err == nil { if acct, err := s.db.GetAccountByUUID(claims.Subject); err == nil {
resp.Username = acct.Username resp.Username = acct.Username
resp.AccountType = string(acct.AccountType)
} }
writeJSON(w, http.StatusOK, resp) writeJSON(w, http.StatusOK, resp)
} }

View File

@@ -19,13 +19,13 @@ import (
"testing" "testing"
"time" "time"
"git.wntrmute.dev/kyle/mcias/internal/auth" "git.wntrmute.dev/mc/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/config" "git.wntrmute.dev/mc/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/policy" "git.wntrmute.dev/mc/mcias/internal/policy"
"git.wntrmute.dev/kyle/mcias/internal/token" "git.wntrmute.dev/mc/mcias/internal/token"
"git.wntrmute.dev/kyle/mcias/internal/vault" "git.wntrmute.dev/mc/mcias/internal/vault"
) )
// generateTOTPCode computes a valid RFC 6238 TOTP code for the current time // generateTOTPCode computes a valid RFC 6238 TOTP code for the current time

View File

@@ -4,10 +4,10 @@ package server
import ( import (
"net/http" "net/http"
"git.wntrmute.dev/kyle/mcias/internal/audit" "git.wntrmute.dev/mc/mcias/internal/audit"
"git.wntrmute.dev/kyle/mcias/internal/middleware" "git.wntrmute.dev/mc/mcias/internal/middleware"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/vault" "git.wntrmute.dev/mc/mcias/internal/vault"
) )
// unsealRequest is the request body for POST /v1/vault/unseal. // unsealRequest is the request body for POST /v1/vault/unseal.

View File

@@ -7,7 +7,7 @@ import (
"strings" "strings"
"testing" "testing"
"git.wntrmute.dev/kyle/mcias/internal/vault" "git.wntrmute.dev/mc/mcias/internal/vault"
) )
func TestHandleHealthSealed(t *testing.T) { func TestHandleHealthSealed(t *testing.T) {

91
internal/sso/session.go Normal file
View File

@@ -0,0 +1,91 @@
package sso
import (
"fmt"
"sync"
"time"
"git.wntrmute.dev/mc/mcias/internal/crypto"
)
const (
sessionTTL = 5 * time.Minute
sessionBytes = 16 // 128 bits of entropy for the nonce
)
// Session holds the SSO parameters between /sso/authorize and login completion.
// The nonce is embedded as a hidden form field in the login page and carried
// through the multi-step login flow (password → TOTP, or WebAuthn).
type Session struct { //nolint:govet // fieldalignment: field order matches logical grouping
ClientID string
RedirectURI string
State string
ExpiresAt time.Time
}
// pendingSessions stores SSO sessions created at /sso/authorize.
var pendingSessions sync.Map //nolint:gochecknoglobals
func init() {
go cleanupSessions()
}
func cleanupSessions() {
ticker := time.NewTicker(cleanupPeriod)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
pendingSessions.Range(func(key, value any) bool {
s, ok := value.(*Session)
if !ok || now.After(s.ExpiresAt) {
pendingSessions.Delete(key)
}
return true
})
}
}
// StoreSession creates and stores a new SSO session, returning the hex-encoded
// nonce that should be embedded in the login form.
func StoreSession(clientID, redirectURI, state string) (string, error) {
raw, err := crypto.RandomBytes(sessionBytes)
if err != nil {
return "", fmt.Errorf("sso: generate session nonce: %w", err)
}
nonce := fmt.Sprintf("%x", raw)
pendingSessions.Store(nonce, &Session{
ClientID: clientID,
RedirectURI: redirectURI,
State: state,
ExpiresAt: time.Now().Add(sessionTTL),
})
return nonce, nil
}
// ConsumeSession retrieves and deletes an SSO session by nonce.
// Returns the Session and true if valid, or (nil, false) if unknown or expired.
func ConsumeSession(nonce string) (*Session, bool) {
v, ok := pendingSessions.LoadAndDelete(nonce)
if !ok {
return nil, false
}
s, ok2 := v.(*Session)
if !ok2 || time.Now().After(s.ExpiresAt) {
return nil, false
}
return s, true
}
// GetSession retrieves an SSO session without consuming it (for read-only checks
// during multi-step login). Returns nil if unknown or expired.
func GetSession(nonce string) *Session {
v, ok := pendingSessions.Load(nonce)
if !ok {
return nil
}
s, ok2 := v.(*Session)
if !ok2 || time.Now().After(s.ExpiresAt) {
return nil
}
return s
}

93
internal/sso/store.go Normal file
View File

@@ -0,0 +1,93 @@
// Package sso implements the authorization code store for the SSO redirect flow.
//
// MCIAS acts as the SSO provider: downstream services (MCR, MCAT, Metacrypt)
// redirect users to MCIAS for login, and MCIAS issues a short-lived, single-use
// authorization code that the service exchanges for a JWT token.
//
// Security design:
// - Authorization codes are 32 random bytes (256 bits), hex-encoded.
// - Codes are single-use: consumed via sync.Map LoadAndDelete on first exchange.
// - Codes expire after 60 seconds to limit the window for interception.
// - A background goroutine evicts expired codes every 5 minutes.
// - The code is bound to the client_id and redirect_uri presented at authorize
// time; the token exchange endpoint must verify both match.
package sso
import (
"fmt"
"sync"
"time"
"git.wntrmute.dev/mc/mcias/internal/crypto"
)
const (
codeTTL = 60 * time.Second
codeBytes = 32 // 256 bits of entropy
cleanupPeriod = 5 * time.Minute
)
// AuthCode is a pending authorization code awaiting exchange for a JWT.
type AuthCode struct { //nolint:govet // fieldalignment: field order matches logical grouping
ClientID string
RedirectURI string
State string
AccountID int64
ExpiresAt time.Time
}
// pendingCodes stores issued authorization codes awaiting exchange.
var pendingCodes sync.Map //nolint:gochecknoglobals
func init() {
go cleanupCodes()
}
func cleanupCodes() {
ticker := time.NewTicker(cleanupPeriod)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
pendingCodes.Range(func(key, value any) bool {
ac, ok := value.(*AuthCode)
if !ok || now.After(ac.ExpiresAt) {
pendingCodes.Delete(key)
}
return true
})
}
}
// Store creates and stores a new authorization code bound to the given
// client_id, redirect_uri, state, and account. Returns the hex-encoded code.
func Store(clientID, redirectURI, state string, accountID int64) (string, error) {
raw, err := crypto.RandomBytes(codeBytes)
if err != nil {
return "", fmt.Errorf("sso: generate authorization code: %w", err)
}
code := fmt.Sprintf("%x", raw)
pendingCodes.Store(code, &AuthCode{
ClientID: clientID,
RedirectURI: redirectURI,
State: state,
AccountID: accountID,
ExpiresAt: time.Now().Add(codeTTL),
})
return code, nil
}
// Consume retrieves and deletes an authorization code. Returns the AuthCode
// and true if the code was valid and not expired, or (nil, false) otherwise.
//
// Security: LoadAndDelete ensures single-use; the code cannot be replayed.
func Consume(code string) (*AuthCode, bool) {
v, ok := pendingCodes.LoadAndDelete(code)
if !ok {
return nil, false
}
ac, ok2 := v.(*AuthCode)
if !ok2 || time.Now().After(ac.ExpiresAt) {
return nil, false
}
return ac, true
}

132
internal/sso/store_test.go Normal file
View File

@@ -0,0 +1,132 @@
package sso
import (
"testing"
"time"
)
func TestStoreAndConsume(t *testing.T) {
code, err := Store("mcr", "https://mcr.example.com/cb", "state123", 42)
if err != nil {
t.Fatalf("Store: %v", err)
}
if code == "" {
t.Fatal("Store returned empty code")
}
ac, ok := Consume(code)
if !ok {
t.Fatal("Consume returned false for valid code")
}
if ac.ClientID != "mcr" {
t.Errorf("ClientID = %q, want %q", ac.ClientID, "mcr")
}
if ac.RedirectURI != "https://mcr.example.com/cb" {
t.Errorf("RedirectURI = %q", ac.RedirectURI)
}
if ac.State != "state123" {
t.Errorf("State = %q", ac.State)
}
if ac.AccountID != 42 {
t.Errorf("AccountID = %d, want 42", ac.AccountID)
}
}
func TestConsumeSingleUse(t *testing.T) {
code, err := Store("mcr", "https://mcr.example.com/cb", "s", 1)
if err != nil {
t.Fatalf("Store: %v", err)
}
if _, ok := Consume(code); !ok {
t.Fatal("first Consume should succeed")
}
if _, ok := Consume(code); ok {
t.Error("second Consume should fail (single-use)")
}
}
func TestConsumeUnknownCode(t *testing.T) {
if _, ok := Consume("nonexistent"); ok {
t.Error("Consume should fail for unknown code")
}
}
func TestConsumeExpiredCode(t *testing.T) {
code, err := Store("mcr", "https://mcr.example.com/cb", "s", 1)
if err != nil {
t.Fatalf("Store: %v", err)
}
// Manually expire the code.
v, loaded := pendingCodes.Load(code)
if !loaded {
t.Fatal("code not found in pendingCodes")
}
ac, ok := v.(*AuthCode)
if !ok {
t.Fatal("unexpected type in pendingCodes")
}
ac.ExpiresAt = time.Now().Add(-1 * time.Second)
if _, ok := Consume(code); ok {
t.Error("Consume should fail for expired code")
}
}
func TestStoreSessionAndConsume(t *testing.T) {
nonce, err := StoreSession("mcr", "https://mcr.example.com/cb", "state456")
if err != nil {
t.Fatalf("StoreSession: %v", err)
}
if nonce == "" {
t.Fatal("StoreSession returned empty nonce")
}
// GetSession should return it without consuming.
s := GetSession(nonce)
if s == nil {
t.Fatal("GetSession returned nil")
}
if s.ClientID != "mcr" {
t.Errorf("ClientID = %q", s.ClientID)
}
// Still available after GetSession.
s2, ok := ConsumeSession(nonce)
if !ok {
t.Fatal("ConsumeSession returned false")
}
if s2.State != "state456" {
t.Errorf("State = %q", s2.State)
}
// Consumed — should be gone.
if _, ok := ConsumeSession(nonce); ok {
t.Error("second ConsumeSession should fail")
}
if GetSession(nonce) != nil {
t.Error("GetSession should return nil after consume")
}
}
func TestConsumeSessionExpired(t *testing.T) {
nonce, err := StoreSession("mcr", "https://mcr.example.com/cb", "s")
if err != nil {
t.Fatalf("StoreSession: %v", err)
}
v, loaded := pendingSessions.Load(nonce)
if !loaded {
t.Fatal("session not found in pendingSessions")
}
sess, ok := v.(*Session)
if !ok {
t.Fatal("unexpected type in pendingSessions")
}
sess.ExpiresAt = time.Now().Add(-1 * time.Second)
if _, ok := ConsumeSession(nonce); ok {
t.Error("ConsumeSession should fail for expired session")
}
}

View File

@@ -3,7 +3,7 @@ package ui
import ( import (
"context" "context"
"git.wntrmute.dev/kyle/mcias/internal/token" "git.wntrmute.dev/mc/mcias/internal/token"
) )
// uiContextKey is the unexported type for UI context values, preventing // uiContextKey is the unexported type for UI context values, preventing

View File

@@ -10,7 +10,7 @@ import (
"fmt" "fmt"
"sync" "sync"
"git.wntrmute.dev/kyle/mcias/internal/vault" "git.wntrmute.dev/mc/mcias/internal/vault"
) )
// CSRFManager implements HMAC-signed Double-Submit Cookie CSRF protection. // CSRFManager implements HMAC-signed Double-Submit Cookie CSRF protection.

View File

@@ -7,11 +7,11 @@ import (
"strconv" "strconv"
"strings" "strings"
"git.wntrmute.dev/kyle/mcias/internal/auth" "git.wntrmute.dev/mc/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/crypto" "git.wntrmute.dev/mc/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/validate" "git.wntrmute.dev/mc/mcias/internal/validate"
) )
// knownRoles lists the built-in roles shown as checkboxes in the roles editor. // knownRoles lists the built-in roles shown as checkboxes in the roles editor.

View File

@@ -4,8 +4,8 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/mc/mcias/internal/model"
) )
const auditPageSize = 50 const auditPageSize = 50

View File

@@ -3,18 +3,19 @@ package ui
import ( import (
"net/http" "net/http"
"git.wntrmute.dev/kyle/mcias/internal/audit" "git.wntrmute.dev/mc/mcias/internal/audit"
"git.wntrmute.dev/kyle/mcias/internal/auth" "git.wntrmute.dev/mc/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/crypto" "git.wntrmute.dev/mc/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/token" "git.wntrmute.dev/mc/mcias/internal/token"
"git.wntrmute.dev/kyle/mcias/internal/validate" "git.wntrmute.dev/mc/mcias/internal/validate"
) )
// handleLoginPage renders the login form. // handleLoginPage renders the login form.
func (u *UIServer) handleLoginPage(w http.ResponseWriter, r *http.Request) { func (u *UIServer) handleLoginPage(w http.ResponseWriter, r *http.Request) {
u.render(w, "login", LoginData{ u.render(w, "login", LoginData{
WebAuthnEnabled: u.cfg.WebAuthnEnabled(), WebAuthnEnabled: u.cfg.WebAuthnEnabled(),
SSONonce: r.URL.Query().Get("sso"),
}) })
} }
@@ -97,6 +98,8 @@ func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
return return
} }
ssoNonce := r.FormValue("sso_nonce")
// TOTP required: issue a server-side nonce and show the TOTP step form. // TOTP required: issue a server-side nonce and show the TOTP step form.
// Security: the nonce replaces the password hidden field (F-02). The password // Security: the nonce replaces the password hidden field (F-02). The password
// is not stored anywhere after this point; only the account ID is retained. // is not stored anywhere after this point; only the account ID is retained.
@@ -110,11 +113,12 @@ func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
u.render(w, "totp_step", LoginData{ u.render(w, "totp_step", LoginData{
Username: username, Username: username,
Nonce: nonce, Nonce: nonce,
SSONonce: ssoNonce,
}) })
return return
} }
u.finishLogin(w, r, acct) u.finishLogin(w, r, acct, ssoNonce)
} }
// handleTOTPStep handles the second POST when totp_step=1 is set. // handleTOTPStep handles the second POST when totp_step=1 is set.
@@ -129,6 +133,7 @@ func (u *UIServer) handleTOTPStep(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username") //nolint:gosec // body already limited by caller username := r.FormValue("username") //nolint:gosec // body already limited by caller
nonce := r.FormValue("totp_nonce") //nolint:gosec // body already limited by caller nonce := r.FormValue("totp_nonce") //nolint:gosec // body already limited by caller
totpCode := r.FormValue("totp_code") //nolint:gosec // body already limited by caller totpCode := r.FormValue("totp_code") //nolint:gosec // body already limited by caller
ssoNonce := r.FormValue("sso_nonce") //nolint:gosec // body already limited by caller
// Security: consume the nonce (single-use); reject if unknown or expired. // Security: consume the nonce (single-use); reject if unknown or expired.
accountID, ok := u.consumeTOTPNonce(nonce) accountID, ok := u.consumeTOTPNonce(nonce)
@@ -172,6 +177,7 @@ func (u *UIServer) handleTOTPStep(w http.ResponseWriter, r *http.Request) {
Error: "invalid TOTP code", Error: "invalid TOTP code",
Username: username, Username: username,
Nonce: newNonce, Nonce: newNonce,
SSONonce: ssoNonce,
}) })
return return
} }
@@ -189,15 +195,36 @@ func (u *UIServer) handleTOTPStep(w http.ResponseWriter, r *http.Request) {
Error: "invalid TOTP code", Error: "invalid TOTP code",
Username: username, Username: username,
Nonce: newNonce, Nonce: newNonce,
SSONonce: ssoNonce,
}) })
return return
} }
u.finishLogin(w, r, acct) u.finishLogin(w, r, acct, ssoNonce)
} }
// finishLogin issues a JWT, sets the session cookie, and redirects to dashboard. // finishLogin issues a JWT, sets the session cookie, and redirects to dashboard.
func (u *UIServer) finishLogin(w http.ResponseWriter, r *http.Request, acct *model.Account) { // When ssoNonce is non-empty, the login is part of an SSO redirect flow: instead
// of setting a session cookie, an authorization code is issued and the user is
// redirected back to the service's callback URL.
func (u *UIServer) finishLogin(w http.ResponseWriter, r *http.Request, acct *model.Account, ssoNonce string) {
// SSO redirect flow: issue authorization code and redirect to service.
if ssoNonce != "" {
if callbackURL, ok := u.buildSSOCallback(r, ssoNonce, acct.ID); ok {
// Security: htmx follows 302 redirects via fetch, which fails
// cross-origin (no CORS on the service callback). Use HX-Redirect
// so htmx performs a full page navigation instead.
if isHTMX(r) {
w.Header().Set("HX-Redirect", callbackURL)
w.WriteHeader(http.StatusOK)
return
}
http.Redirect(w, r, callbackURL, http.StatusFound)
return
}
// SSO session expired/consumed — fall through to normal login.
}
// Determine token expiry based on admin role. // Determine token expiry based on admin role.
expiry := u.cfg.DefaultExpiry() expiry := u.cfg.DefaultExpiry()
roles, err := u.db.GetRoles(acct.ID) roles, err := u.db.GetRoles(acct.ID)

View File

@@ -3,8 +3,8 @@ package ui
import ( import (
"net/http" "net/http"
"git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/mc/mcias/internal/model"
) )
// handleDashboard renders the main dashboard page. Admin users see account // handleDashboard renders the main dashboard page. Admin users see account

Some files were not shown because too many files have changed in this diff Show More