Starting over.

This commit is contained in:
2025-11-16 18:36:28 -08:00
parent 22eabe83fc
commit 07a5e957af
52 changed files with 46 additions and 7355 deletions

View File

@@ -1,232 +0,0 @@
# MCIAS golangci-lint configuration
# This is a strict configuration focused on security and code quality
#
# Usage:
# - Run all linters: golangci-lint run
# - Run specific linter: golangci-lint run --disable-all --enable=gosec
# - Run with specific configuration: golangci-lint run -c .golangci.yml
#
# This configuration enables a comprehensive set of linters to ensure:
# 1. Security best practices (gosec, errcheck, etc.)
# 2. Code quality and maintainability (gofmt, goimports, etc.)
# 3. Performance considerations (prealloc, etc.)
# 4. Error handling correctness (errcheck, errorlint, etc.)
#
# For more information about golangci-lint, visit: https://golangci-lint.run/
run:
# Timeout for running linters, default is 1m
timeout: 5m
# Include test files
tests: true
# Go version to use for analysis
go: "1.22"
# Output configuration
output:
# Format: colored-line-number|line-number|json|tab|checkstyle|code-climate|junit-xml|github-actions
formats:
- format: colored-line-number
# Print lines of code with issue
print-issued-lines: true
# Print linter name in the end of issue text
print-linter-name: true
# All available linters
linters:
enable-all: false
disable-all: true
enable:
# Default linters
- errcheck # Detect unchecked errors
- gosimple # Simplify code
- govet # Examine Go source code and reports suspicious constructs
- ineffassign # Detect ineffectual assignments
- staticcheck # Go static analysis
- typecheck # Like the front-end of a Go compiler
- unused # Check for unused constants, variables, functions and types
# Additional linters for security and code quality
- asciicheck # Check that your code does not contain non-ASCII identifiers
- bodyclose # Checks whether HTTP response body is closed successfully
- cyclop # Check function and package cyclomatic complexity
- dupl # Code clone detection
- durationcheck # Check for two durations multiplied together
- errorlint # Find code that will cause problems with the error wrapping scheme
- exhaustive # Check exhaustiveness of enum switch statements
- copyloopvar # Check for pointers to enclosing loop variables (replaces exportloopref)
- forbidigo # Forbids identifiers
- funlen # Tool for detection of long functions
- goconst # Find repeated strings that could be replaced by a constant
- gocritic # Provides diagnostics that check for bugs, performance and style issues
- gocyclo # Calculate cyclomatic complexities of functions
- godot # Check if comments end in a period
- gofmt # Check whether code was gofmt-ed
- goimports # Check imports are formatted according to goimports
- mnd # Detect magic numbers (replaces gomnd)
- gosec # Inspects source code for security problems
- misspell # Find commonly misspelled English words
- nakedret # Find naked returns
- nestif # Reports deeply nested if statements
- noctx # Find sending HTTP request without context.Context
- nolintlint # Reports ill-formed or insufficient nolint directives
- prealloc # Find slice declarations that could potentially be preallocated
- predeclared # Find code that shadows predeclared identifiers
- revive # Fast, configurable, extensible, flexible, and beautiful linter for Go
- sqlclosecheck # Checks that sql.Rows and sql.Stmt are closed
- stylecheck # Stylecheck is a replacement for golint
- thelper # Detect golang test helpers without t.Helper() call
- tparallel # Detects inappropriate usage of t.Parallel()
- unconvert # Remove unnecessary type conversions
- unparam # Find unused function parameters
- wastedassign # Find wasted assignment statements
- whitespace # Tool for detection of leading and trailing whitespace
# Linter settings
linters-settings:
errcheck:
# Report about not checking of errors in type assertions: `a := b.(MyStruct)`.
check-type-assertions: true
# Report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`.
check-blank: true
funlen:
# Checks the number of lines in a function.
lines: 100
# Checks the number of statements in a function.
statements: 50
gocyclo:
# Minimal code complexity to report.
min-complexity: 15
cyclop:
# The maximal code complexity to report.
max-complexity: 15
# The maximal average package complexity.
package-average: 10.0
mnd:
# List of enabled checks, see https://github.com/tommy-muehle/go-mnd/#checks for description.
checks:
- argument
- case
- condition
- operation
- return
- assign
forbidigo:
# Forbid the following identifiers
forbid:
- ^print$
- ^println$
# Exclude godoc examples from forbidigo checks
exclude_godoc_examples: true
govet:
# Enable all analyzers.
enable-all: true
# Disable specific analyzers.
disable:
- fieldalignment # Too strict for now
# Settings per analyzer.
settings:
shadow:
# Whether to be strict about shadowing; can be noisy.
strict: true
revive:
# Maximum number of open files at the same time.
max-open-files: 2048
# Minimal confidence for issues, default is 0.8.
confidence: 0.8
# Enable all available rules.
enable-all-rules: true
# Disabled rules.
rules:
- name: line-length-limit
disabled: true
staticcheck:
# https://staticcheck.io/docs/options#checks
checks: ["all"]
stylecheck:
# https://staticcheck.io/docs/options#checks
checks: ["all"]
gosec:
# To select a subset of rules to run.
# Available rules: https://github.com/securego/gosec#available-rules
includes:
- G101 # Look for hard coded credentials
- G102 # Bind to all interfaces
- G103 # Audit the use of unsafe block
- G104 # Audit errors not checked
- G106 # Audit the use of ssh.InsecureIgnoreHostKey
- G107 # Url provided to HTTP request as taint input
- G108 # Profiling endpoint automatically exposed
- G109 # Potential Integer overflow made by strconv.Atoi result conversion to int16/32
- G110 # Potential DoS vulnerability via decompression bomb
- G111 # Potential directory traversal
- G112 # Potential slowloris attack
- G113 # Usage of Rat.SetString in math/big
- G114 # Use of net/http serve function that has no support for setting timeouts
- G201 # SQL query construction using format string
- G202 # SQL query construction using string concatenation
- G203 # Use of unescaped data in HTML templates
- G204 # Audit use of command execution
- G301 # Poor file permissions used when creating a directory
- G302 # Poor file permissions used when creation of file
- G303 # Creating tempfile using a predictable path
- G304 # File path provided as taint input
- G305 # File traversal when extracting zip/tar archive
- G306 # Poor file permissions used when writing to a file
- G307 # Deferring a method which returns an error
- G401 # Detect the usage of weak crypto algorithms
- G402 # Look for bad TLS connection settings
- G403 # Ensure minimum RSA key length of 2048 bits
- G404 # Insecure random number source (rand)
- G501 # Import blocklist: crypto/md5
- G502 # Import blocklist: crypto/des
- G503 # Import blocklist: crypto/rc4
- G504 # Import blocklist: net/http/cgi
- G505 # Import blocklist: crypto/sha1
- G601 # Implicit memory aliasing of items from a range statement
- G602 # Slice access out of bounds
# Issues configuration
issues:
# Maximum count of issues with the same text.
max-same-issues: 3
# Maximum issues count per one linter.
max-issues-per-linter: 50
# Fix found issues (if it's supported by the linter).
fix: false
# Exclude some directories from linting
exclude-dirs:
- vendor
# Exclude some files from linting
exclude-files:
- ".*\\.pb\\.go$"
- ".*\\.gen\\.go$"
# Exclude specific linting rules for specific files
exclude-rules:
# Exclude some linters from running on tests files.
- path: _test\.go
linters:
- gocyclo
- errcheck
- dupl
- gosec
- funlen
- thelper # Many test helpers don't need t.Helper()
- noctx # Context is often not needed in tests
- cyclop # Test functions can be more complex
- nestif # Test functions often have nested if statements

View File

@@ -3,6 +3,49 @@
MCIAS is the metacircular identity and access system, providing identity and MCIAS is the metacircular identity and access system, providing identity and
authentication across the metacircular projects. authentication across the metacircular projects.
The Metacircular Identity and Access System (MCIAS) provides standard
tools for user and access management among metacircular and wntrmute
systems.
Build an authentication service written in Go that I can use with other
apps that I write.
## Specifications
- Applications should be able to either do an interactive login, using a
username/password (and potentially a TOTP), or present a token.
- Applications should be able to renew the token, which would nominally
expire after some period (defaulting to maybe 30 days).
- There are two kinds of users: human and system accounts.
- System accounts can only present a token; they have a single token
associated with that account at a time.
- User accounts have roles associated with them.
- Users with the admin role can issue tokens for any app, or users with
the role named the same as a service account can issue tokens for that
service account.
- Admin users can also revoke tokens for a service account.
- Service accounts (and users with the a role named the same as the
service account) can also retrieve Postgres database credentials for
the service account.
## Technical details
- User passwords will be stored using scrypt.
- The service account tokens and user/password authentication can be
used to obtain a JWT, if that is appropriate.
- All authentication events should be logged.
- This service should use the packages contained in
git.wntrmute.dev/kyle/goutils for logging etc.
## Interfaces
- The primary interface will be an REST API over HTTPS. TLS security is
critical for this.
- There should be two command line tools associated with MCIAS:
- mciassrv is the authentication server.
- mciasctl is the tool for admins to create and manage accounts, issue
or revoke tokens, and manage postgres database credentials.
## Structure ## Structure
+ The system should be runnable through a cobra CLI tool, with + The system should be runnable through a cobra CLI tool, with

View File

@@ -1,556 +0,0 @@
package api
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"git.wntrmute.dev/kyle/mcias/data"
"github.com/Masterminds/squirrel"
"github.com/oklog/ulid/v2"
)
type LoginRequest struct {
Version string `json:"version"`
Login data.Login `json:"login"`
}
type TokenResponse struct {
Token string `json:"token"`
Expires int64 `json:"expires"`
}
type TOTPVerifyRequest struct {
Version string `json:"version"`
Username string `json:"username"`
TOTPCode string `json:"totp_code"`
}
type ErrorResponse struct {
Error string `json:"error"`
}
type DatabaseCredentials struct {
Host string `json:"host"`
Port int `json:"port"`
Name string `json:"name"`
User string `json:"user"`
Password string `json:"password"`
}
func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) {
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.sendError(w, "Invalid request format", http.StatusBadRequest)
return
}
if req.Version != "v1" || req.Login.User == "" || req.Login.Password == "" {
s.sendError(w, "Invalid login request", http.StatusBadRequest)
return
}
user, err := s.getUserByUsername(req.Login.User)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
s.sendError(w, "Invalid username or password", http.StatusUnauthorized)
} else {
s.Logger.Printf("Database error: %v", err)
s.sendError(w, "Internal server error", http.StatusInternalServerError)
}
return
}
// Check password only
if !user.CheckPassword(&req.Login) {
s.sendError(w, "Invalid username or password", http.StatusUnauthorized)
return
}
// Password is correct, create a token regardless of TOTP status
token, expires, err := s.createToken(user.ID)
if err != nil {
s.Logger.Printf("Token creation error: %v", err)
// Log the security event
details := map[string]string{
"reason": "Token creation error",
"error": err.Error(),
}
s.LogSecurityEvent(r, "login_attempt", user.ID, user.User, false, details)
s.sendError(w, "Internal server error", http.StatusInternalServerError)
return
}
// If user has TOTP enabled, include this information in the response
totpEnabled := user.HasTOTP()
// Log successful login
details := map[string]string{
"token_expires": time.Unix(expires, 0).UTC().Format(time.RFC3339),
"totp_enabled": fmt.Sprintf("%t", totpEnabled),
}
s.LogSecurityEvent(r, "login_success", user.ID, user.User, true, details)
// Include TOTP status in the response
response := struct {
TokenResponse
TOTPEnabled bool `json:"totp_enabled"`
}{
TokenResponse: TokenResponse{
Token: token,
Expires: expires,
},
TOTPEnabled: totpEnabled,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(response); err != nil {
s.Logger.Printf("Error encoding response: %v", err)
}
}
func (s *Server) handleTokenLogin(w http.ResponseWriter, r *http.Request) {
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.sendError(w, "Invalid request format", http.StatusBadRequest)
return
}
if req.Version != "v1" || req.Login.User == "" || req.Login.Token == "" {
s.sendError(w, "Invalid login request", http.StatusBadRequest)
return
}
// Verify the token is valid
_, err := s.verifyToken(req.Login.User, req.Login.Token)
if err != nil {
s.sendError(w, "Invalid or expired token", http.StatusUnauthorized)
return
}
// Renew the existing token instead of creating a new one
expires, err := s.renewToken(req.Login.User, req.Login.Token)
if err != nil {
s.Logger.Printf("Token renewal error: %v", err)
s.sendError(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(TokenResponse{
Token: req.Login.Token,
Expires: expires,
}); err != nil {
s.Logger.Printf("Error encoding response: %v", err)
}
}
func (s *Server) sendError(w http.ResponseWriter, message string, status int) {
// Log the detailed error message for debugging
s.Logger.Printf("Error response: %s (Status: %d)", message, status)
// Create a generic error message based on status code
publicMessage := "An error occurred processing your request"
errorCode := fmt.Sprintf("E%000d", status)
// Customize public messages for common status codes
// but don't leak specific details about the error
switch status {
case http.StatusBadRequest:
publicMessage = "Invalid request format"
case http.StatusUnauthorized:
publicMessage = "Authentication required"
case http.StatusForbidden:
publicMessage = "Insufficient permissions"
case http.StatusNotFound:
publicMessage = "Resource not found"
case http.StatusTooManyRequests:
publicMessage = "Rate limit exceeded"
case http.StatusInternalServerError:
publicMessage = "Internal server error"
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
response := struct {
Error string `json:"error"`
ErrorCode string `json:"error_code"`
}{
Error: publicMessage,
ErrorCode: errorCode,
}
if err := json.NewEncoder(w).Encode(response); err != nil {
s.Logger.Printf("Error encoding error response: %v", err)
}
}
func (s *Server) getUserByUsername(username string) (*data.User, error) {
// Use squirrel to build the query safely
query, args, err := squirrel.Select("id", "created", "user", "password", "salt", "totp_secret").
From("users").
Where(squirrel.Eq{"user": username}).
ToSql()
if err != nil {
return nil, err
}
row := s.DB.QueryRow(query, args...)
user := &data.User{}
var totpSecret sql.NullString
err = row.Scan(&user.ID, &user.Created, &user.User, &user.Password, &user.Salt, &totpSecret)
if err != nil {
return nil, err
}
// Set TOTP secret if it exists
if totpSecret.Valid {
user.TOTPSecret = totpSecret.String
}
// Use squirrel to build the roles query safely
rolesQuery, rolesArgs, err := squirrel.Select("r.role").
From("roles r").
Join("user_roles ur ON r.id = ur.rid").
Where(squirrel.Eq{"ur.uid": user.ID}).
ToSql()
if err != nil {
return nil, err
}
rows, err := s.DB.Query(rolesQuery, rolesArgs...)
if err != nil {
return nil, err
}
defer rows.Close()
var roles []string
for rows.Next() {
var role string
if err := rows.Scan(&role); err != nil {
return nil, err
}
roles = append(roles, role)
}
user.Roles = roles
return user, nil
}
func (s *Server) createToken(userID string) (string, int64, error) {
// Generate 16 bytes of random data
tokenBytes := make([]byte, 16)
if _, err := rand.Read(tokenBytes); err != nil {
return "", 0, err
}
// Hex encode the random bytes to get a 32-character string.
token := hex.EncodeToString(tokenBytes)
expires := time.Now().Add(24 * time.Hour).Unix()
tokenID := ulid.Make().String()
// Use squirrel to build the insert query safely
query, args, err := squirrel.Insert("tokens").
Columns("id", "uid", "token", "expires").
Values(tokenID, userID, token, expires).
ToSql()
if err != nil {
return "", 0, err
}
_, err = s.DB.Exec(query, args...)
if err != nil {
return "", 0, err
}
return token, expires, nil
}
func (s *Server) verifyToken(username, token string) (string, error) {
// Use squirrel to build the query safely
query, args, err := squirrel.Select("t.uid", "t.expires").
From("tokens t").
Join("users u ON t.uid = u.id").
Where(squirrel.And{
squirrel.Eq{"u.user": username},
squirrel.Eq{"t.token": token},
}).
ToSql()
if err != nil {
return "", err
}
var userID string
var expires int64
err = s.DB.QueryRow(query, args...).Scan(&userID, &expires)
if err != nil {
return "", err
}
if expires > 0 && expires < time.Now().Unix() {
return "", errors.New("token expired")
}
return userID, nil
}
func (s *Server) renewToken(username, token string) (int64, error) {
// First, verify the token exists and get the token ID
// Use squirrel to build the query safely
query, args, err := squirrel.Select("t.id").
From("tokens t").
Join("users u ON t.uid = u.id").
Where(squirrel.And{
squirrel.Eq{"u.user": username},
squirrel.Eq{"t.token": token},
}).
ToSql()
if err != nil {
return 0, err
}
var tokenID string
err = s.DB.QueryRow(query, args...).Scan(&tokenID)
if err != nil {
return 0, err
}
// Update the token's expiry time
expires := time.Now().Add(24 * time.Hour).Unix()
// Use squirrel to build the update query safely
updateQuery, updateArgs, err := squirrel.Update("tokens").
Set("expires", expires).
Where(squirrel.Eq{"id": tokenID}).
ToSql()
if err != nil {
return 0, err
}
_, err = s.DB.Exec(updateQuery, updateArgs...)
if err != nil {
return 0, err
}
return expires, nil
}
func (s *Server) handleTOTPVerify(w http.ResponseWriter, r *http.Request) {
var req TOTPVerifyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.sendError(w, "Invalid request format", http.StatusBadRequest)
return
}
if req.Version != "v1" || req.Username == "" || req.TOTPCode == "" {
s.sendError(w, "Invalid TOTP verification request", http.StatusBadRequest)
return
}
user, err := s.getUserByUsername(req.Username)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
s.sendError(w, "User not found", http.StatusUnauthorized)
} else {
s.Logger.Printf("Database error: %v", err)
s.sendError(w, "Internal server error", http.StatusInternalServerError)
}
return
}
// Check if TOTP is enabled for the user
if !user.HasTOTP() {
// Log the security event
details := map[string]string{
"reason": "TOTP not enabled for user",
}
s.LogSecurityEvent(r, "totp_verification_attempt", user.ID, user.User, false, details)
s.sendError(w, "TOTP not enabled for user", http.StatusBadRequest)
return
}
// Validate the TOTP code
valid, validErr := user.ValidateTOTPCode(req.TOTPCode)
if validErr != nil || !valid {
// Log the security event
details := map[string]string{
"reason": "Invalid TOTP code",
}
s.LogSecurityEvent(r, "totp_verification_attempt", user.ID, user.User, false, details)
s.sendError(w, "Invalid TOTP code", http.StatusUnauthorized)
return
}
// TOTP code is valid, create a token
token, expires, err := s.createToken(user.ID)
if err != nil {
s.Logger.Printf("Token creation error: %v", err)
// Log the security event
details := map[string]string{
"reason": "Token creation error",
"error": err.Error(),
}
s.LogSecurityEvent(r, "totp_verification_attempt", user.ID, user.User, false, details)
s.sendError(w, "Internal server error", http.StatusInternalServerError)
return
}
// Log successful TOTP verification
details := map[string]string{
"token_expires": time.Unix(expires, 0).UTC().Format(time.RFC3339),
}
s.LogSecurityEvent(r, "totp_verification_success", user.ID, user.User, true, details)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(TokenResponse{
Token: token,
Expires: expires,
}); err != nil {
s.Logger.Printf("Error encoding response: %v", err)
}
}
func (s *Server) handleDatabaseCredentials(w http.ResponseWriter, r *http.Request) {
// Extract authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
s.sendError(w, "Authorization header required", http.StatusUnauthorized)
return
}
// Check if it's a Bearer token
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
s.sendError(w, "Invalid authorization format", http.StatusUnauthorized)
return
}
token := parts[1]
username := r.URL.Query().Get("username")
if username == "" {
s.sendError(w, "Username parameter required", http.StatusBadRequest)
return
}
// Verify the token
userID, err := s.verifyToken(username, token)
if err != nil {
s.sendError(w, "Invalid or expired token", http.StatusUnauthorized)
return
}
// Check if user has permission to read database credentials
user, err := s.getUserByUsername(username)
if err != nil {
s.Logger.Printf("Database error: %v", err)
s.sendError(w, "Internal server error", http.StatusInternalServerError)
return
}
hasPermission, err := user.HasPermission(s.Auth, "database_credentials", "read")
if err != nil {
s.Logger.Printf("Permission check error: %v", err)
s.sendError(w, "Internal server error", http.StatusInternalServerError)
return
}
if !hasPermission {
s.sendError(w, "Insufficient permissions: requires database_credentials:read permission", http.StatusForbidden)
return
}
// Get database ID from query parameter if provided
databaseID := r.URL.Query().Get("database_id")
// Build the query to retrieve databases the user has access to
queryBuilder := squirrel.Select("d.id", "d.host", "d.port", "d.name", "d.user", "d.password").
From("database d")
// If the user is an admin, they can see all databases
isAdmin := false
for _, role := range user.Roles {
if role == "admin" {
isAdmin = true
break
}
}
if !isAdmin {
// Non-admin users can only see databases they're explicitly associated with
queryBuilder = queryBuilder.Join("database_users du ON d.id = du.db_id").
Where(squirrel.Eq{"du.uid": userID})
}
// If a specific database ID was requested, filter by that
if databaseID != "" {
queryBuilder = queryBuilder.Where(squirrel.Eq{"d.id": databaseID})
}
query, args, err := queryBuilder.ToSql()
if err != nil {
s.Logger.Printf("Query building error: %v", err)
s.sendError(w, "Internal server error", http.StatusInternalServerError)
return
}
rows, err := s.DB.Query(query, args...)
if err != nil {
s.Logger.Printf("Database error: %v", err)
s.sendError(w, "Internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
var databases []DatabaseCredentials
for rows.Next() {
var id string
var creds DatabaseCredentials
err = rows.Scan(&id, &creds.Host, &creds.Port, &creds.Name, &creds.User, &creds.Password)
if err != nil {
s.Logger.Printf("Row scanning error: %v", err)
s.sendError(w, "Internal server error", http.StatusInternalServerError)
return
}
databases = append(databases, creds)
}
if err = rows.Err(); err != nil {
s.Logger.Printf("Rows iteration error: %v", err)
s.sendError(w, "Internal server error", http.StatusInternalServerError)
return
}
if len(databases) == 0 {
s.sendError(w, "No database credentials found", http.StatusNotFound)
return
}
// If a specific database was requested, return just that one
if databaseID != "" && len(databases) == 1 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(databases[0]); err != nil {
s.Logger.Printf("Error encoding response: %v", err)
}
return
}
// Otherwise return all accessible databases
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(databases); err != nil {
s.Logger.Printf("Error encoding response: %v", err)
}
}

View File

@@ -1,568 +0,0 @@
package api
import (
"bytes"
"database/sql"
"encoding/json"
"log"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"git.wntrmute.dev/kyle/mcias/data"
_ "github.com/mattn/go-sqlite3"
)
func setupTestDB(t *testing.T) *sql.DB {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("Failed to open test database: %v", err)
}
schema, err := os.ReadFile("../database/schema.sql")
if err != nil {
t.Fatalf("Failed to read schema: %v", err)
}
if _, err := db.Exec(string(schema)); err != nil {
t.Fatalf("Failed to initialize test database: %v", err)
}
return db
}
func createTestUser(t *testing.T, db *sql.DB) *data.User {
user := &data.User{}
login := &data.Login{
User: "testuser",
Password: "testpassword",
}
if err := user.Register(login); err != nil {
t.Fatalf("Failed to register test user: %v", err)
}
query := `INSERT INTO users (id, created, user, password, salt, totp_secret) VALUES (?, ?, ?, ?, ?, ?)`
_, err := db.Exec(query, user.ID, user.Created, user.User, user.Password, user.Salt, nil)
if err != nil {
t.Fatalf("Failed to insert test user: %v", err)
}
return user
}
func TestPasswordLogin(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
user := createTestUser(t, db)
logger := log.New(os.Stdout, "TEST: ", log.LstdFlags)
server := NewServer(db, logger)
loginReq := LoginRequest{
Version: "v1",
Login: data.Login{
User: user.User,
Password: "testpassword",
},
}
body, err := json.Marshal(loginReq)
if err != nil {
t.Fatalf("Failed to marshal request: %v", err)
}
req := httptest.NewRequest("POST", "/v1/login/password", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
server.handlePasswordLogin(recorder, req)
if recorder.Code != http.StatusOK {
t.Errorf("Expected status code %d, got %d", http.StatusOK, recorder.Code)
}
var response TokenResponse
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if response.Token == "" {
t.Error("Expected token in response, got empty string")
}
now := time.Now().Unix()
if response.Expires <= now {
t.Errorf("Expected token expiration in the future, got %d (now: %d)", response.Expires, now)
}
}
func TestTokenLogin(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
user := createTestUser(t, db)
logger := log.New(os.Stdout, "TEST: ", log.LstdFlags)
server := NewServer(db, logger)
token := "testtoken123456"
initialExpires := time.Now().Add(1 * time.Hour).Unix() // Set initial expiry to 1 hour from now
tokenID := "token123"
query := `INSERT INTO tokens (id, uid, token, expires) VALUES (?, ?, ?, ?)`
_, err := db.Exec(query, tokenID, user.ID, token, initialExpires)
if err != nil {
t.Fatalf("Failed to insert test token: %v", err)
}
loginReq := LoginRequest{
Version: "v1",
Login: data.Login{
User: user.User,
Token: token,
},
}
body, err := json.Marshal(loginReq)
if err != nil {
t.Fatalf("Failed to marshal request: %v", err)
}
req := httptest.NewRequest("POST", "/v1/login/token", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
server.handleTokenLogin(recorder, req)
if recorder.Code != http.StatusOK {
t.Errorf("Expected status code %d, got %d", http.StatusOK, recorder.Code)
}
var response TokenResponse
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
// Verify that the same token is returned
if response.Token != token {
t.Errorf("Expected the same token '%s', got '%s'", token, response.Token)
}
// Verify that the expiry has been renewed (should be later than the initial expiry)
if response.Expires <= initialExpires {
t.Errorf("Expected renewed expiry to be later than initial expiry %d, got %d", initialExpires, response.Expires)
}
now := time.Now().Unix()
if response.Expires <= now {
t.Errorf("Expected token expiration in the future, got %d (now: %d)", response.Expires, now)
}
// Verify that the token in the database has been updated
var dbExpires int64
err = db.QueryRow("SELECT expires FROM tokens WHERE id = ?", tokenID).Scan(&dbExpires)
if err != nil {
t.Fatalf("Failed to query token from database: %v", err)
}
if dbExpires != response.Expires {
t.Errorf("Database expiry %d does not match response expiry %d", dbExpires, response.Expires)
}
}
func TestInvalidPasswordLogin(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
user := createTestUser(t, db)
logger := log.New(os.Stdout, "TEST: ", log.LstdFlags)
server := NewServer(db, logger)
loginReq := LoginRequest{
Version: "v1",
Login: data.Login{
User: user.User,
Password: "wrongpassword",
},
}
body, err := json.Marshal(loginReq)
if err != nil {
t.Fatalf("Failed to marshal request: %v", err)
}
req := httptest.NewRequest("POST", "/v1/login/password", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
server.handlePasswordLogin(recorder, req)
if recorder.Code != http.StatusUnauthorized {
t.Errorf("Expected status code %d, got %d", http.StatusUnauthorized, recorder.Code)
}
}
func TestInvalidTokenLogin(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
user := createTestUser(t, db)
logger := log.New(os.Stdout, "TEST: ", log.LstdFlags)
server := NewServer(db, logger)
loginReq := LoginRequest{
Version: "v1",
Login: data.Login{
User: user.User,
Token: "invalidtoken",
},
}
body, err := json.Marshal(loginReq)
if err != nil {
t.Fatalf("Failed to marshal request: %v", err)
}
req := httptest.NewRequest("POST", "/v1/login/token", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
server.handleTokenLogin(recorder, req)
if recorder.Code != http.StatusUnauthorized {
t.Errorf("Expected status code %d, got %d", http.StatusUnauthorized, recorder.Code)
}
}
func TestTOTPLogin(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Create a user with TOTP enabled
user := createTestUser(t, db)
// Generate a TOTP secret for the user
secret, err := user.GenerateTOTPSecret()
if err != nil {
t.Fatalf("Failed to generate TOTP secret: %v", err)
}
// Update the user in the database with the TOTP secret
_, err = db.Exec("UPDATE users SET totp_secret = ? WHERE id = ?", secret, user.ID)
if err != nil {
t.Fatalf("Failed to update user with TOTP secret: %v", err)
}
// Generate a valid TOTP code
valid, err := user.ValidateTOTPCode("123456")
if err != nil {
t.Fatalf("Failed to validate TOTP code: %v", err)
}
t.Logf("TOTP validation result: %v", valid)
// Try to login without a TOTP code
logger := log.New(os.Stdout, "TEST: ", log.LstdFlags)
server := NewServer(db, logger)
loginReq := LoginRequest{
Version: "v1",
Login: data.Login{
User: user.User,
Password: "testpassword",
},
}
body, err := json.Marshal(loginReq)
if err != nil {
t.Fatalf("Failed to marshal request: %v", err)
}
req := httptest.NewRequest("POST", "/v1/login/password", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
server.handlePasswordLogin(recorder, req)
// Should get an unauthorized response with a message about TOTP being required
if recorder.Code != http.StatusUnauthorized {
t.Errorf("Expected status code %d, got %d", http.StatusUnauthorized, recorder.Code)
}
var errorResp ErrorResponse
if err := json.NewDecoder(recorder.Body).Decode(&errorResp); err != nil {
t.Fatalf("Failed to decode error response: %v", err)
}
if errorResp.Error != "TOTP code required" {
t.Errorf("Expected error message 'TOTP code required', got '%s'", errorResp.Error)
}
// Now try to login with a TOTP code
// Note: In a real test, we would generate a valid TOTP code, but for this test
// we'll just use a hardcoded value since we can't easily generate a valid code
// without the actual TOTP algorithm implementation.
loginReq.Login.TOTPCode = "123456"
body, err = json.Marshal(loginReq)
if err != nil {
t.Fatalf("Failed to marshal request: %v", err)
}
req = httptest.NewRequest("POST", "/v1/login/password", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
recorder = httptest.NewRecorder()
server.handlePasswordLogin(recorder, req)
// The test will likely fail here since we're using a hardcoded TOTP code,
// but the test structure is correct. In a real environment with a proper
// TOTP implementation, this would work.
t.Logf("Login with TOTP code status: %d", recorder.Code)
}
func createTestAdminUser(t *testing.T, db *sql.DB) *data.User {
user := createTestUser(t, db)
// Use the existing admin role from schema.sql
var roleID string
err := db.QueryRow("SELECT id FROM roles WHERE role = 'admin'").Scan(&roleID)
if err != nil {
t.Fatalf("Failed to get admin role ID: %v", err)
}
// Assign admin role to user
userRoleID := "ur123"
_, err = db.Exec("INSERT INTO user_roles (id, uid, rid) VALUES (?, ?, ?)", userRoleID, user.ID, roleID)
if err != nil {
t.Fatalf("Failed to assign admin role to user: %v", err)
}
user.Roles = []string{"admin"}
return user
}
func createTestDBOperatorUser(t *testing.T, db *sql.DB) *data.User {
// Create a new user
user := &data.User{}
login := &data.Login{
User: "dboperator",
Password: "testpassword",
}
if err := user.Register(login); err != nil {
t.Fatalf("Failed to register test user: %v", err)
}
query := `INSERT INTO users (id, created, user, password, salt) VALUES (?, ?, ?, ?, ?)`
_, err := db.Exec(query, user.ID, user.Created, user.User, user.Password, user.Salt)
if err != nil {
t.Fatalf("Failed to insert test user: %v", err)
}
// Use the existing db_operator role from schema.sql
var roleID string
err = db.QueryRow("SELECT id FROM roles WHERE role = 'db_operator'").Scan(&roleID)
if err != nil {
t.Fatalf("Failed to get db_operator role ID: %v", err)
}
// Assign db_operator role to user
userRoleID := "ur456"
_, err = db.Exec("INSERT INTO user_roles (id, uid, rid) VALUES (?, ?, ?)", userRoleID, user.ID, roleID)
if err != nil {
t.Fatalf("Failed to assign db_operator role to user: %v", err)
}
user.Roles = []string{"db_operator"}
return user
}
func insertTestDatabaseCredentials(t *testing.T, db *sql.DB) {
query := `INSERT INTO database (id, host, port, name, user, password)
VALUES (?, ?, ?, ?, ?, ?)`
_, err := db.Exec(query, "db123", "localhost", 5432, "testdb", "postgres", "securepassword")
if err != nil {
t.Fatalf("Failed to insert test database credentials: %v", err)
}
}
func TestDatabaseCredentialsAdmin(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
user := createTestAdminUser(t, db)
insertTestDatabaseCredentials(t, db)
logger := log.New(os.Stdout, "TEST: ", log.LstdFlags)
server := NewServer(db, logger)
token := "testtoken123456"
expires := time.Now().Add(24 * time.Hour).Unix()
tokenID := "token123"
query := `INSERT INTO tokens (id, uid, token, expires) VALUES (?, ?, ?, ?)`
_, err := db.Exec(query, tokenID, user.ID, token, expires)
if err != nil {
t.Fatalf("Failed to insert test token: %v", err)
}
req := httptest.NewRequest("GET", "/v1/database/credentials?username="+user.User, nil)
req.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
server.handleDatabaseCredentials(recorder, req)
if recorder.Code != http.StatusOK {
t.Errorf("Expected status code %d, got %d", http.StatusOK, recorder.Code)
}
var response DatabaseCredentials
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if response.Host != "localhost" {
t.Errorf("Expected host 'localhost', got '%s'", response.Host)
}
if response.Port != 5432 {
t.Errorf("Expected port 5432, got %d", response.Port)
}
if response.Name != "testdb" {
t.Errorf("Expected database name 'testdb', got '%s'", response.Name)
}
if response.User != "postgres" {
t.Errorf("Expected user 'postgres', got '%s'", response.User)
}
if response.Password != "securepassword" {
t.Errorf("Expected password 'securepassword', got '%s'", response.Password)
}
}
func TestDatabaseCredentialsDBOperator(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
user := createTestDBOperatorUser(t, db)
insertTestDatabaseCredentials(t, db)
logger := log.New(os.Stdout, "TEST: ", log.LstdFlags)
server := NewServer(db, logger)
token := "dboptoken123456"
expires := time.Now().Add(24 * time.Hour).Unix()
tokenID := "token456"
query := `INSERT INTO tokens (id, uid, token, expires) VALUES (?, ?, ?, ?)`
_, err := db.Exec(query, tokenID, user.ID, token, expires)
if err != nil {
t.Fatalf("Failed to insert test token: %v", err)
}
req := httptest.NewRequest("GET", "/v1/database/credentials?username="+user.User, nil)
req.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
server.handleDatabaseCredentials(recorder, req)
if recorder.Code != http.StatusOK {
t.Errorf("Expected status code %d, got %d", http.StatusOK, recorder.Code)
}
var response DatabaseCredentials
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if response.Host != "localhost" {
t.Errorf("Expected host 'localhost', got '%s'", response.Host)
}
if response.Port != 5432 {
t.Errorf("Expected port 5432, got %d", response.Port)
}
if response.Name != "testdb" {
t.Errorf("Expected database name 'testdb', got '%s'", response.Name)
}
if response.User != "postgres" {
t.Errorf("Expected user 'postgres', got '%s'", response.User)
}
if response.Password != "securepassword" {
t.Errorf("Expected password 'securepassword', got '%s'", response.Password)
}
}
func TestDatabaseCredentialsUnauthorized(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Create a regular user with the 'user' role
user := &data.User{}
login := &data.Login{
User: "regularuser",
Password: "testpassword",
}
if err := user.Register(login); err != nil {
t.Fatalf("Failed to register test user: %v", err)
}
query := `INSERT INTO users (id, created, user, password, salt) VALUES (?, ?, ?, ?, ?)`
_, err := db.Exec(query, user.ID, user.Created, user.User, user.Password, user.Salt)
if err != nil {
t.Fatalf("Failed to insert test user: %v", err)
}
// Use the existing user role from schema.sql
var roleID string
err = db.QueryRow("SELECT id FROM roles WHERE role = 'user'").Scan(&roleID)
if err != nil {
t.Fatalf("Failed to get user role ID: %v", err)
}
// Assign user role to user
userRoleID := "ur789"
_, err = db.Exec("INSERT INTO user_roles (id, uid, rid) VALUES (?, ?, ?)", userRoleID, user.ID, roleID)
if err != nil {
t.Fatalf("Failed to assign user role to user: %v", err)
}
insertTestDatabaseCredentials(t, db)
logger := log.New(os.Stdout, "TEST: ", log.LstdFlags)
server := NewServer(db, logger)
token := "usertoken123456"
expires := time.Now().Add(24 * time.Hour).Unix()
tokenID := "token789"
tokenQuery := `INSERT INTO tokens (id, uid, token, expires) VALUES (?, ?, ?, ?)`
_, err = db.Exec(tokenQuery, tokenID, user.ID, token, expires)
if err != nil {
t.Fatalf("Failed to insert test token: %v", err)
}
req := httptest.NewRequest("GET", "/v1/database/credentials?username="+user.User, nil)
req.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
server.handleDatabaseCredentials(recorder, req)
if recorder.Code != http.StatusForbidden {
t.Errorf("Expected status code %d, got %d", http.StatusForbidden, recorder.Code)
}
// Check that the error message mentions the required permission
var errResp ErrorResponse
if err := json.NewDecoder(recorder.Body).Decode(&errResp); err != nil {
t.Fatalf("Failed to decode error response: %v", err)
}
expectedErrMsg := "Insufficient permissions: requires database_credentials:read permission"
if errResp.Error != expectedErrMsg {
t.Errorf("Expected error message '%s', got '%s'", expectedErrMsg, errResp.Error)
}
}

View File

@@ -1,217 +0,0 @@
package api
import (
"database/sql"
"encoding/json"
"log"
"net"
"net/http"
"sync"
"time"
"git.wntrmute.dev/kyle/mcias/data"
_ "github.com/mattn/go-sqlite3"
"golang.org/x/time/rate"
)
// client represents a client with rate limiting information
type client struct {
limiter *rate.Limiter
lastSeen time.Time
}
// SecurityEvent represents a security-related event
type SecurityEvent struct {
Timestamp string `json:"timestamp"`
EventType string `json:"event_type"`
UserID string `json:"user_id,omitempty"`
Username string `json:"username,omitempty"`
IPAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"`
RequestURI string `json:"request_uri"`
Success bool `json:"success"`
Details map[string]string `json:"details,omitempty"`
}
// RateLimiter manages rate limiting for clients
type RateLimiter struct {
clients map[string]*client
mu sync.Mutex
// Requests per second, burst size
rate rate.Limit
burst int
}
// NewRateLimiter creates a new rate limiter
func NewRateLimiter(r rate.Limit, b int) *RateLimiter {
return &RateLimiter{
clients: make(map[string]*client),
rate: r,
burst: b,
}
}
// GetLimiter returns a rate limiter for a client
func (rl *RateLimiter) GetLimiter(ip string) *rate.Limiter {
rl.mu.Lock()
defer rl.mu.Unlock()
c, exists := rl.clients[ip]
if !exists {
c = &client{
limiter: rate.NewLimiter(rl.rate, rl.burst),
lastSeen: time.Now(),
}
rl.clients[ip] = c
} else {
c.lastSeen = time.Now()
}
return c.limiter
}
// CleanupClients removes old clients
func (rl *RateLimiter) CleanupClients() {
rl.mu.Lock()
defer rl.mu.Unlock()
for ip, client := range rl.clients {
if time.Since(client.lastSeen) > 1*time.Hour {
delete(rl.clients, ip)
}
}
}
type Server struct {
DB *sql.DB
Router *http.ServeMux
Logger *log.Logger
Auth *data.AuthorizationService
RateLimiter *RateLimiter
}
// getClientIP extracts the client IP address from the request
func getClientIP(r *http.Request) string {
// Check for X-Forwarded-For header first (for clients behind proxy)
ip := r.Header.Get("X-Forwarded-For")
if ip != "" {
// X-Forwarded-For can contain multiple IPs, use the first one
ips := net.ParseIP(ip)
if ips != nil {
return ips.String()
}
}
// Fall back to RemoteAddr
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return ip
}
func NewServer(db *sql.DB, logger *log.Logger) *Server {
// Create a rate limiter with 10 requests per second and burst of 30
rateLimiter := NewRateLimiter(10, 30)
s := &Server{
DB: db,
Router: http.NewServeMux(),
Logger: logger,
Auth: data.NewAuthorizationService(db),
RateLimiter: rateLimiter,
}
// Start a goroutine to clean up old clients
go func() {
for {
time.Sleep(1 * time.Hour)
rateLimiter.CleanupClients()
}
}()
s.registerRoutes()
return s
}
func (s *Server) registerRoutes() {
s.Router.HandleFunc("POST /v1/login/password", s.handlePasswordLogin)
s.Router.HandleFunc("POST /v1/login/token", s.handleTokenLogin)
s.Router.HandleFunc("POST /v1/login/totp", s.handleTOTPVerify)
s.Router.HandleFunc("GET /v1/database/credentials", s.handleDatabaseCredentials)
}
// sendRateLimitExceeded sends a rate limit exceeded response
func (s *Server) sendRateLimitExceeded(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
response := map[string]string{
"error": "Rate limit exceeded. Please try again later.",
}
if err := json.NewEncoder(w).Encode(response); err != nil {
s.Logger.Printf("Error encoding rate limit response: %v", err)
}
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Get client IP
clientIP := getClientIP(r)
// Get rate limiter for this client
limiter := s.RateLimiter.GetLimiter(clientIP)
// Check if rate limit is exceeded
if !limiter.Allow() {
s.Logger.Printf("Rate limit exceeded for IP: %s, URI: %s", clientIP, r.RequestURI)
s.sendRateLimitExceeded(w)
return
}
// Apply stricter rate limiting for authentication endpoints
if r.URL.Path == "/v1/login/password" || r.URL.Path == "/v1/login/token" || r.URL.Path == "/v1/login/totp" {
// Use a separate limiter with lower rate for auth endpoints
authLimiter := rate.NewLimiter(rate.Limit(1), 5) // 1 request per second, burst of 5
if !authLimiter.Allow() {
s.Logger.Printf("Auth rate limit exceeded for IP: %s, URI: %s", clientIP, r.RequestURI)
s.sendRateLimitExceeded(w)
return
}
}
// Proceed with the request
s.Router.ServeHTTP(w, r)
}
// LogSecurityEvent logs a security-related event
func (s *Server) LogSecurityEvent(r *http.Request, eventType string, userID, username string, success bool, details map[string]string) {
event := SecurityEvent{
Timestamp: time.Now().UTC().Format(time.RFC3339),
EventType: eventType,
UserID: userID,
Username: username,
IPAddress: getClientIP(r),
UserAgent: r.UserAgent(),
RequestURI: r.RequestURI,
Success: success,
Details: details,
}
// Convert to JSON for structured logging
eventJSON, err := json.Marshal(event)
if err != nil {
s.Logger.Printf("Error marshaling security event: %v", err)
return
}
// Log the security event
s.Logger.Printf("SECURITY_EVENT: %s", eventJSON)
}
func (s *Server) Start(addr string) error {
s.Logger.Printf("Starting server on %s", addr)
s.Logger.Printf("Note: This server is designed to run behind a reverse proxy that handles TLS")
return http.ListenAndServe(addr, s)
}

View File

@@ -1,177 +0,0 @@
#+TITLE: MCIAS Client SDK
The MCIAS Client SDK provides a Go client for interacting with the Metacircular Identity and Access System (MCIAS). It allows applications to authenticate users and retrieve database credentials from an MCIAS server.
* Installation
#+BEGIN_SRC bash
go get git.wntrmute.dev/kyle/mcias/client
#+END_SRC
* Usage
** Creating a Client
#+BEGIN_SRC go
import "git.wntrmute.dev/kyle/mcias/client"
// Create a client with default settings (connects to http://localhost:8080)
c := client.NewClient()
// Create a client with custom settings
c := client.NewClient(
client.WithBaseURL("https://mcias.example.com"),
client.WithUsername("username"),
client.WithToken("existing-token"),
)
#+END_SRC
** Authentication
*** Password Authentication
#+BEGIN_SRC go
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
tokenResp, err := c.LoginWithPassword(ctx, "username", "password")
if err != nil {
log.Fatalf("Failed to login: %v", err)
}
fmt.Printf("Authenticated with token: %s\n", tokenResp.Token)
fmt.Printf("Token expires at: %s\n", time.Unix(tokenResp.Expires, 0).Format(time.RFC3339))
// Check if TOTP verification is required
if tokenResp.TOTPEnabled {
fmt.Println("TOTP verification required")
// See TOTP Verification section
}
#+END_SRC
*** Token Authentication
#+BEGIN_SRC go
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
tokenResp, err := c.LoginWithToken(ctx, "username", "existing-token")
if err != nil {
log.Fatalf("Failed to login with token: %v", err)
}
fmt.Printf("Authenticated with token: %s\n", tokenResp.Token)
fmt.Printf("Token expires at: %s\n", time.Unix(tokenResp.Expires, 0).Format(time.RFC3339))
#+END_SRC
*** TOTP Verification
If TOTP is enabled for a user, you'll need to verify a TOTP code after password authentication:
#+BEGIN_SRC go
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
totpResp, err := c.VerifyTOTP(ctx, "username", "123456") // Replace with actual TOTP code
if err != nil {
log.Fatalf("Failed to verify TOTP: %v", err)
}
fmt.Printf("TOTP verified, token: %s\n", totpResp.Token)
fmt.Printf("Token expires at: %s\n", time.Unix(totpResp.Expires, 0).Format(time.RFC3339))
#+END_SRC
** Retrieving Database Credentials
Once authenticated, you can retrieve database credentials:
#+BEGIN_SRC go
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
dbCreds, err := c.GetDatabaseCredentials(ctx)
if err != nil {
log.Fatalf("Failed to get database credentials: %v", err)
}
fmt.Printf("Database Host: %s\n", dbCreds.Host)
fmt.Printf("Database Port: %d\n", dbCreds.Port)
fmt.Printf("Database Name: %s\n", dbCreds.Name)
fmt.Printf("Database User: %s\n", dbCreds.User)
fmt.Printf("Database Password: %s\n", dbCreds.Password)
#+END_SRC
* Complete Example
Here's a complete example showing the authentication flow and database credential retrieval:
#+BEGIN_SRC go
package main
import (
"context"
"fmt"
"log"
"time"
"git.wntrmute.dev/kyle/mcias/client"
)
func main() {
// Create a new client
c := client.NewClient()
// Create a context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Authenticate with username and password
tokenResp, err := c.LoginWithPassword(ctx, "username", "password")
if err != nil {
log.Fatalf("Failed to login: %v", err)
}
fmt.Printf("Authenticated with token: %s\n", tokenResp.Token)
fmt.Printf("Token expires at: %s\n", time.Unix(tokenResp.Expires, 0).Format(time.RFC3339))
// If TOTP is enabled, verify the TOTP code
if tokenResp.TOTPEnabled {
fmt.Println("TOTP is enabled, please enter your TOTP code")
var totpCode string
fmt.Scanln(&totpCode)
totpResp, err := c.VerifyTOTP(ctx, "username", totpCode)
if err != nil {
log.Fatalf("Failed to verify TOTP: %v", err)
}
fmt.Printf("TOTP verified, new token: %s\n", totpResp.Token)
fmt.Printf("Token expires at: %s\n", time.Unix(totpResp.Expires, 0).Format(time.RFC3339))
}
// Get database credentials
dbCreds, err := c.GetDatabaseCredentials(ctx)
if err != nil {
log.Fatalf("Failed to get database credentials: %v", err)
}
fmt.Printf("Database Host: %s\n", dbCreds.Host)
fmt.Printf("Database Port: %d\n", dbCreds.Port)
fmt.Printf("Database Name: %s\n", dbCreds.Name)
fmt.Printf("Database User: %s\n", dbCreds.User)
fmt.Printf("Database Password: %s\n", dbCreds.Password)
}
#+END_SRC
* Error Handling
All methods return errors that should be checked. The errors include detailed information about what went wrong, including API error messages when available.
* Configuration Options
The client can be configured with the following options:
- =WithBaseURL(baseURL string)=: Sets the base URL of the MCIAS server (default: "http://localhost:8080")
- =WithHTTPClient(httpClient *http.Client)=: Sets a custom HTTP client (default: http.Client with 10s timeout)
- =WithToken(token string)=: Sets an authentication token
- =WithUsername(username string)=: Sets a username

View File

@@ -1,212 +0,0 @@
package client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// LoginParams represents the parameters for login.
type LoginParams struct {
User string `json:"user"`
Password string `json:"password,omitempty"`
Token string `json:"token,omitempty"`
TOTPCode string `json:"totp_code,omitempty"`
}
// LoginRequest represents a login request to the MCIAS API.
type LoginRequest struct {
Version string `json:"version"`
Login LoginParams `json:"login"`
}
// TOTPVerifyRequest represents a TOTP verification request.
type TOTPVerifyRequest struct {
Version string `json:"version"`
Username string `json:"username"`
TOTPCode string `json:"totp_code"`
}
// TokenResponse represents the response from a login or TOTP verification request.
type TokenResponse struct {
Token string `json:"token"`
Expires int64 `json:"expires"`
TOTPEnabled bool `json:"totp_enabled,omitempty"`
}
// ErrorResponse represents an error response from the API.
type ErrorResponse struct {
Error string `json:"error"`
ErrorCode string `json:"error_code,omitempty"`
}
// LoginWithPassword authenticates with the MCIAS server using a username and password.
// If TOTP is enabled for the user, the TOTPEnabled field in the response will be true,
// and the client will need to call VerifyTOTP to complete authentication.
func (c *Client) LoginWithPassword(ctx context.Context, username, password string) (*TokenResponse, error) {
loginReq := LoginRequest{
Version: "v1",
Login: LoginParams{
User: username,
Password: password,
},
}
jsonData, err := json.Marshal(loginReq)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
url := fmt.Sprintf("%s/v1/login/password", c.BaseURL)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
var errResp ErrorResponse
if unmarshalErr := json.Unmarshal(body, &errResp); unmarshalErr == nil {
return nil, fmt.Errorf("API error: %s (code: %s)", errResp.Error, errResp.ErrorCode)
}
return nil, fmt.Errorf("API error: %s", resp.Status)
}
var tokenResp TokenResponse
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
// Update client with authentication info
c.Token = tokenResp.Token
c.Username = username
return &tokenResp, nil
}
// LoginWithToken authenticates with the MCIAS server using a token.
func (c *Client) LoginWithToken(ctx context.Context, username, token string) (*TokenResponse, error) {
loginReq := LoginRequest{
Version: "v1",
Login: LoginParams{
User: username,
Token: token,
},
}
jsonData, err := json.Marshal(loginReq)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
url := fmt.Sprintf("%s/v1/login/token", c.BaseURL)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
var errResp ErrorResponse
if unmarshalErr := json.Unmarshal(body, &errResp); unmarshalErr == nil {
return nil, fmt.Errorf("API error: %s (code: %s)", errResp.Error, errResp.ErrorCode)
}
return nil, fmt.Errorf("API error: %s", resp.Status)
}
var tokenResp TokenResponse
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
// Update client with authentication info
c.Token = tokenResp.Token
c.Username = username
return &tokenResp, nil
}
// VerifyTOTP verifies a TOTP code for a user.
func (c *Client) VerifyTOTP(ctx context.Context, username, totpCode string) (*TokenResponse, error) {
totpReq := TOTPVerifyRequest{
Version: "v1",
Username: username,
TOTPCode: totpCode,
}
jsonData, err := json.Marshal(totpReq)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
url := fmt.Sprintf("%s/v1/login/totp", c.BaseURL)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
var errResp ErrorResponse
if unmarshalErr := json.Unmarshal(body, &errResp); unmarshalErr == nil {
return nil, fmt.Errorf("API error: %s (code: %s)", errResp.Error, errResp.ErrorCode)
}
return nil, fmt.Errorf("API error: %s", resp.Status)
}
var tokenResp TokenResponse
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
// Update client with authentication info
c.Token = tokenResp.Token
c.Username = username
return &tokenResp, nil
}
// IsTokenExpired checks if the token is expired.
func (c *Client) IsTokenExpired(expiryTime int64) bool {
return expiryTime > 0 && expiryTime < time.Now().Unix()
}

View File

@@ -1,60 +0,0 @@
// Package client provides a Go client for interacting with the MCIAS (Metacircular Identity and Access System).
package client
import (
"net/http"
"time"
)
// Client encapsulates the connection details and authentication state needed to interact with the MCIAS API.
type Client struct {
BaseURL string
HTTPClient *http.Client
Token string
Username string
}
type Option func(*Client)
func WithBaseURL(baseURL string) Option {
return func(c *Client) {
c.BaseURL = baseURL
}
}
func WithHTTPClient(httpClient *http.Client) Option {
return func(c *Client) {
c.HTTPClient = httpClient
}
}
func WithToken(token string) Option {
return func(c *Client) {
c.Token = token
}
}
func WithUsername(username string) Option {
return func(c *Client) {
c.Username = username
}
}
func NewClient(options ...Option) *Client {
client := &Client{
BaseURL: "http://localhost:8080",
HTTPClient: &http.Client{
Timeout: 10 * time.Second,
},
}
for _, option := range options {
option(client)
}
return client
}
func (c *Client) IsAuthenticated() bool {
return c.Token != ""
}

View File

@@ -1,80 +0,0 @@
package client
import (
"net/http"
"testing"
"time"
)
func TestNewClient(t *testing.T) {
// Test default client
client := NewClient()
if client.BaseURL != "http://localhost:8080" {
t.Errorf("Expected BaseURL to be http://localhost:8080, got %s", client.BaseURL)
}
if client.HTTPClient == nil {
t.Error("Expected HTTPClient to be initialized")
}
if client.Token != "" {
t.Errorf("Expected Token to be empty, got %s", client.Token)
}
if client.Username != "" {
t.Errorf("Expected Username to be empty, got %s", client.Username)
}
// Test client with options
customHTTPClient := &http.Client{Timeout: 5 * time.Second}
client = NewClient(
WithBaseURL("https://mcias.example.com"),
WithHTTPClient(customHTTPClient),
WithToken("test-token"),
WithUsername("test-user"),
)
if client.BaseURL != "https://mcias.example.com" {
t.Errorf("Expected BaseURL to be https://mcias.example.com, got %s", client.BaseURL)
}
if client.HTTPClient != customHTTPClient {
t.Error("Expected HTTPClient to be the custom client")
}
if client.Token != "test-token" {
t.Errorf("Expected Token to be test-token, got %s", client.Token)
}
if client.Username != "test-user" {
t.Errorf("Expected Username to be test-user, got %s", client.Username)
}
}
func TestIsAuthenticated(t *testing.T) {
// Test unauthenticated client
client := NewClient()
if client.IsAuthenticated() {
t.Error("Expected IsAuthenticated to return false for client without token")
}
// Test authenticated client
client = NewClient(WithToken("test-token"))
if !client.IsAuthenticated() {
t.Error("Expected IsAuthenticated to return true for client with token")
}
}
func TestIsTokenExpired(t *testing.T) {
client := NewClient()
// Test expired token
pastTime := time.Now().Add(-1 * time.Hour).Unix()
if !client.IsTokenExpired(pastTime) {
t.Error("Expected IsTokenExpired to return true for past time")
}
// Test valid token
futureTime := time.Now().Add(1 * time.Hour).Unix()
if client.IsTokenExpired(futureTime) {
t.Error("Expected IsTokenExpired to return false for future time")
}
// Test zero time (no expiry)
if client.IsTokenExpired(0) {
t.Error("Expected IsTokenExpired to return false for zero time")
}
}

View File

@@ -1,157 +0,0 @@
package client
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
)
// DatabaseCredentials represents the database connection credentials.
type DatabaseCredentials struct {
Host string `json:"host"`
Port int `json:"port"`
Name string `json:"name"`
User string `json:"user"`
Password string `json:"password"`
}
// GetDatabaseCredentials retrieves database credentials from the MCIAS server.
// If databaseID is provided, it returns credentials for that specific database.
// If databaseID is empty, it returns the first database the user has access to.
// This method requires the client to be authenticated (have a valid token).
func (c *Client) GetDatabaseCredentials(ctx context.Context, databaseID string) (*DatabaseCredentials, error) {
if !c.IsAuthenticated() {
return nil, fmt.Errorf("client is not authenticated, call LoginWithPassword or LoginWithToken first")
}
if c.Username == "" {
return nil, fmt.Errorf("username is not set, call LoginWithPassword or LoginWithToken first")
}
// Build the URL with query parameters
baseURL := fmt.Sprintf("%s/v1/database/credentials", c.BaseURL)
params := url.Values{}
params.Add("username", c.Username)
if databaseID != "" {
params.Add("database_id", databaseID)
}
requestURL := fmt.Sprintf("%s?%s", baseURL, params.Encode())
// Create the request
req, err := http.NewRequestWithContext(ctx, "GET", requestURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Add authorization header
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.Token))
// Send the request
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
// Read the response body
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
// Check for errors
if resp.StatusCode != http.StatusOK {
var errResp ErrorResponse
if unmarshalErr := json.Unmarshal(body, &errResp); unmarshalErr == nil {
return nil, fmt.Errorf("API error: %s (code: %s)", errResp.Error, errResp.ErrorCode)
}
return nil, fmt.Errorf("API error: %s", resp.Status)
}
// Try to parse as a single database first (when a specific database_id is requested)
var creds DatabaseCredentials
if err := json.Unmarshal(body, &creds); err == nil {
// Successfully parsed as a single database
return &creds, nil
}
// If that fails, try to parse as an array of databases
var credsList []DatabaseCredentials
if err := json.Unmarshal(body, &credsList); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
// If we got an empty list, return an error
if len(credsList) == 0 {
return nil, fmt.Errorf("no database credentials found")
}
// Return the first database in the list
return &credsList[0], nil
}
// GetDatabaseCredentialsList retrieves all database credentials the user has access to.
// This method requires the client to be authenticated (have a valid token).
func (c *Client) GetDatabaseCredentialsList(ctx context.Context) ([]DatabaseCredentials, error) {
if !c.IsAuthenticated() {
return nil, fmt.Errorf("client is not authenticated, call LoginWithPassword or LoginWithToken first")
}
if c.Username == "" {
return nil, fmt.Errorf("username is not set, call LoginWithPassword or LoginWithToken first")
}
// Build the URL with query parameters
baseURL := fmt.Sprintf("%s/v1/database/credentials", c.BaseURL)
params := url.Values{}
params.Add("username", c.Username)
requestURL := fmt.Sprintf("%s?%s", baseURL, params.Encode())
// Create the request
req, err := http.NewRequestWithContext(ctx, "GET", requestURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Add authorization header
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.Token))
// Send the request
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
// Read the response body
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
// Check for errors
if resp.StatusCode != http.StatusOK {
var errResp ErrorResponse
if unmarshalErr := json.Unmarshal(body, &errResp); unmarshalErr == nil {
return nil, fmt.Errorf("API error: %s (code: %s)", errResp.Error, errResp.ErrorCode)
}
return nil, fmt.Errorf("API error: %s", resp.Status)
}
// Try to parse as an array of databases
var credsList []DatabaseCredentials
if err := json.Unmarshal(body, &credsList); err != nil {
// If that fails, try to parse as a single database
var creds DatabaseCredentials
if err := json.Unmarshal(body, &creds); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
// Return as a single-item list
return []DatabaseCredentials{creds}, nil
}
return credsList, nil
}

View File

@@ -1,210 +0,0 @@
package client_test
import (
"context"
"fmt"
"time"
"git.wntrmute.dev/kyle/mcias/client"
)
func Example() {
c := client.NewClient()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
tokenResp, err := c.LoginWithPassword(ctx, "username", "password")
if err != nil {
fmt.Println("Failed to login:", err)
return
}
fmt.Printf("Authenticated with token: %s\n", tokenResp.Token)
fmt.Printf("Token expires at: %s\n", time.Unix(tokenResp.Expires, 0).Format(time.RFC3339))
if tokenResp.TOTPEnabled {
fmt.Println("TOTP is enabled, please enter your TOTP code")
totpCode := "123456" // In a real application, this would be user input
totpResp, err := c.VerifyTOTP(ctx, "username", totpCode)
if err != nil {
fmt.Println("Failed to verify TOTP:", err)
return
}
fmt.Printf("TOTP verified, new token: %s\n", totpResp.Token)
fmt.Printf("Token expires at: %s\n", time.Unix(totpResp.Expires, 0).Format(time.RFC3339))
}
dbCreds, err := c.GetDatabaseCredentials(ctx, "")
if err != nil {
fmt.Println("Failed to get database credentials:", err)
return
}
fmt.Printf("Database Host: %s\n", dbCreds.Host)
fmt.Printf("Database Port: %d\n", dbCreds.Port)
fmt.Printf("Database Name: %s\n", dbCreds.Name)
fmt.Printf("Database User: %s\n", dbCreds.User)
fmt.Printf("Database Password: %s\n", dbCreds.Password)
tokenClient := client.NewClient()
tokenResp, err = tokenClient.LoginWithToken(ctx, "username", "existing-token")
if err != nil {
fmt.Println("Failed to login with token:", err)
return
}
fmt.Printf("Authenticated with token: %s\n", tokenResp.Token)
fmt.Printf("Token expires at: %s\n", time.Unix(tokenResp.Expires, 0).Format(time.RFC3339))
// Output:
// Authenticated with token: token
// Token expires at: 2023-01-01T00:00:00Z
// Database Host: db.example.com
// Database Port: 5432
// Database Name: mydb
// Database User: dbuser
// Database Password: dbpass
// Authenticated with token: token
// Token expires at: 2023-01-01T00:00:00Z
}
func ExampleClient_LoginWithPassword() {
c := client.NewClient(
client.WithBaseURL("https://mcias.example.com"),
)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
tokenResp, err := c.LoginWithPassword(ctx, "username", "password")
if err != nil {
fmt.Println("Failed to login:", err)
return
}
fmt.Printf("Authenticated with token: %s\n", tokenResp.Token)
fmt.Printf("Token expires at: %s\n", time.Unix(tokenResp.Expires, 0).Format(time.RFC3339))
if tokenResp.TOTPEnabled {
fmt.Println("TOTP verification required")
}
// Output:
// Authenticated with token: token
// Token expires at: 2023-01-01T00:00:00Z
}
func ExampleClient_LoginWithToken() {
c := client.NewClient()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
tokenResp, err := c.LoginWithToken(ctx, "username", "existing-token")
if err != nil {
fmt.Println("Failed to login with token:", err)
return
}
fmt.Printf("Authenticated with token: %s\n", tokenResp.Token)
fmt.Printf("Token expires at: %s\n", time.Unix(tokenResp.Expires, 0).Format(time.RFC3339))
// Output:
// Authenticated with token: token
// Token expires at: 2023-01-01T00:00:00Z
}
func ExampleClient_VerifyTOTP() {
c := client.NewClient()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
totpResp, err := c.VerifyTOTP(ctx, "username", "123456")
if err != nil {
fmt.Println("Failed to verify TOTP:", err)
return
}
fmt.Printf("TOTP verified, token: %s\n", totpResp.Token)
fmt.Printf("Token expires at: %s\n", time.Unix(totpResp.Expires, 0).Format(time.RFC3339))
// Output:
// TOTP verified, token: token
// Token expires at: 2023-01-01T00:00:00Z
}
func ExampleClient_GetDatabaseCredentials() {
c := client.NewClient(
client.WithUsername("username"),
client.WithToken("existing-token"),
)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
databaseID := "db123"
dbCreds, err := c.GetDatabaseCredentials(ctx, databaseID)
if err != nil {
fmt.Println("Failed to get database credentials:", err)
return
}
fmt.Printf("Database Host: %s\n", dbCreds.Host)
fmt.Printf("Database Port: %d\n", dbCreds.Port)
fmt.Printf("Database Name: %s\n", dbCreds.Name)
fmt.Printf("Database User: %s\n", dbCreds.User)
fmt.Printf("Database Password: %s\n", dbCreds.Password)
// Output:
// Database Host: db.example.com
// Database Port: 5432
// Database Name: mydb
// Database User: dbuser
// Database Password: dbpass
}
func ExampleClient_GetDatabaseCredentialsList() {
c := client.NewClient(
client.WithUsername("username"),
client.WithToken("existing-token"),
)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
dbCredsList, err := c.GetDatabaseCredentialsList(ctx)
if err != nil {
fmt.Println("Failed to get database credentials list:", err)
return
}
fmt.Printf("Number of databases: %d\n", len(dbCredsList))
for i, creds := range dbCredsList {
fmt.Printf("Database %d:\n", i+1)
fmt.Printf(" Host: %s\n", creds.Host)
fmt.Printf(" Port: %d\n", creds.Port)
fmt.Printf(" Name: %s\n", creds.Name)
fmt.Printf(" User: %s\n", creds.User)
fmt.Printf(" Password: %s\n", creds.Password)
}
// Output:
// Number of databases: 2
// Database 1:
// Host: db1.example.com
// Port: 5432
// Name: mydb1
// User: dbuser1
// Password: dbpass1
// Database 2:
// Host: db2.example.com
// Port: 5432
// Name: mydb2
// User: dbuser2
// Password: dbpass2
}

View File

@@ -1,185 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"time"
"git.wntrmute.dev/kyle/mcias/client"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var (
dbUsername string
dbToken string
useStored bool
databaseID string
outputJSON bool
)
var databaseCmd = &cobra.Command{
Use: "database",
Short: "Manage database credentials",
Long: `Commands for managing database credentials in the MCIAS system.`,
}
var getCredentialsCmd = &cobra.Command{
Use: "credentials",
Short: "Get database credentials",
Long: `Retrieve database credentials from the MCIAS system.
This command requires authentication with a username and token.
If database-id is provided, it returns credentials for that specific database.
If database-id is not provided, it returns the first database the user has access to.`,
Run: func(_ *cobra.Command, args []string) {
getCredentials()
},
}
var listCredentialsCmd = &cobra.Command{
Use: "list",
Short: "List all accessible database credentials",
Long: `List all database credentials the user has access to.
This command requires authentication with a username and token.`,
Run: func(_ *cobra.Command, args []string) {
listCredentials()
},
}
func init() {
rootCmd.AddCommand(databaseCmd)
databaseCmd.AddCommand(getCredentialsCmd)
databaseCmd.AddCommand(listCredentialsCmd)
// Flags for getCredentialsCmd
getCredentialsCmd.Flags().StringVarP(&dbUsername, "username", "u", "", "Username for authentication")
getCredentialsCmd.Flags().StringVarP(&dbToken, "token", "t", "", "Authentication token")
getCredentialsCmd.Flags().BoolVarP(&useStored, "use-stored", "s", false, "Use stored token from previous login")
getCredentialsCmd.Flags().StringVarP(&databaseID, "database-id", "d", "", "ID of the specific database to retrieve")
getCredentialsCmd.Flags().BoolVarP(&outputJSON, "json", "j", false, "Output in JSON format")
// Flags for listCredentialsCmd
listCredentialsCmd.Flags().StringVarP(&dbUsername, "username", "u", "", "Username for authentication")
listCredentialsCmd.Flags().StringVarP(&dbToken, "token", "t", "", "Authentication token")
listCredentialsCmd.Flags().BoolVarP(&useStored, "use-stored", "s", false, "Use stored token from previous login")
listCredentialsCmd.Flags().BoolVarP(&outputJSON, "json", "j", false, "Output in JSON format")
// Make username required only if not using stored token
getCredentialsCmd.MarkFlagsMutuallyExclusive("token", "use-stored")
listCredentialsCmd.MarkFlagsMutuallyExclusive("token", "use-stored")
}
// createClient creates and configures a new MCIAS client
func createClient() *client.Client {
// If using stored token, load it from the token file
if useStored {
tokenInfo, err := loadToken()
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading token: %v\n", err)
os.Exit(1)
}
dbUsername = tokenInfo.Username
dbToken = tokenInfo.Token
}
// Validate required parameters
if dbUsername == "" {
fmt.Fprintf(os.Stderr, "Error: username is required\n")
os.Exit(1)
}
if dbToken == "" {
fmt.Fprintf(os.Stderr, "Error: token is required (either provide --token or use --use-stored)\n")
os.Exit(1)
}
serverAddr := viper.GetString("server")
if serverAddr == "" {
serverAddr = "http://localhost:8080"
}
// Create a new client with the appropriate options
c := client.NewClient(
client.WithBaseURL(serverAddr),
client.WithUsername(dbUsername),
client.WithToken(dbToken),
)
return c
}
func getCredentials() {
// Create a new client
c := createClient()
// Create a context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Get database credentials
creds, err := c.GetDatabaseCredentials(ctx, databaseID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error retrieving database credentials: %v\n", err)
os.Exit(1)
}
// Output in JSON format if requested
if outputJSON {
jsonData, err := json.MarshalIndent(creds, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "Error formatting JSON: %v\n", err)
os.Exit(1)
}
fmt.Println(string(jsonData))
return
}
// Output in human-readable format
fmt.Println("Database Credentials:")
fmt.Printf("Host: %s\n", creds.Host)
fmt.Printf("Port: %d\n", creds.Port)
fmt.Printf("Name: %s\n", creds.Name)
fmt.Printf("User: %s\n", creds.User)
fmt.Printf("Password: %s\n", creds.Password)
}
func listCredentials() {
// Create a new client
c := createClient()
// Create a context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Get all database credentials
credsList, err := c.GetDatabaseCredentialsList(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "Error retrieving database credentials: %v\n", err)
os.Exit(1)
}
// Output in JSON format if requested
if outputJSON {
jsonData, err := json.MarshalIndent(credsList, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "Error formatting JSON: %v\n", err)
os.Exit(1)
}
fmt.Println(string(jsonData))
return
}
// Output in human-readable format
fmt.Printf("Found %d database(s):\n\n", len(credsList))
for i, creds := range credsList {
fmt.Printf("Database #%d:\n", i+1)
fmt.Printf(" Host: %s\n", creds.Host)
fmt.Printf(" Port: %d\n", creds.Port)
fmt.Printf(" Name: %s\n", creds.Name)
fmt.Printf(" User: %s\n", creds.User)
fmt.Printf(" Password: %s\n", creds.Password)
fmt.Println()
}
}

View File

@@ -1,368 +0,0 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var (
username string
password string
token string
totpCode string
)
type LoginRequest struct {
Version string `json:"version"`
Login LoginParams `json:"login"`
}
type TOTPVerifyRequest struct {
Version string `json:"version"`
Username string `json:"username"`
TOTPCode string `json:"totp_code"`
}
type LoginParams struct {
User string `json:"user"`
Password string `json:"password,omitempty"`
Token string `json:"token,omitempty"`
TOTPCode string `json:"totp_code,omitempty"`
}
type TokenResponse struct {
Token string `json:"token"`
Expires int64 `json:"expires"`
}
type ErrorResponse struct {
Error string `json:"error"`
}
type TokenInfo struct {
Username string `json:"username"`
Token string `json:"token"`
Expires int64 `json:"expires"`
}
var loginCmd = &cobra.Command{
Use: "login",
Short: "Login to the MCIAS server",
Long: `Login to the MCIAS server using either a username/password or a token.`,
}
var passwordLoginCmd = &cobra.Command{
Use: "password",
Short: "Login with username and password",
Long: `Login to the MCIAS server using a username and password.`,
Run: func(cmd *cobra.Command, args []string) {
loginWithPassword()
},
}
var tokenLoginCmd = &cobra.Command{
Use: "token",
Short: "Login with a token",
Long: `Login to the MCIAS server using a token.`,
Run: func(cmd *cobra.Command, args []string) {
loginWithToken()
},
}
var totpVerifyCmd = &cobra.Command{
Use: "totp",
Short: "Verify TOTP code",
Long: `Verify a TOTP code after password authentication.`,
Run: func(cmd *cobra.Command, args []string) {
verifyTOTP()
},
}
func init() {
rootCmd.AddCommand(loginCmd)
loginCmd.AddCommand(passwordLoginCmd)
loginCmd.AddCommand(tokenLoginCmd)
loginCmd.AddCommand(totpVerifyCmd)
// TOTP verification flags
totpVerifyCmd.Flags().StringVarP(&username, "username", "u", "", "Username for authentication")
totpVerifyCmd.Flags().StringVarP(&totpCode, "code", "c", "", "TOTP code to verify")
if err := totpVerifyCmd.MarkFlagRequired("username"); err != nil {
fmt.Fprintf(os.Stderr, "Error marking username flag as required: %v\n", err)
}
if err := totpVerifyCmd.MarkFlagRequired("code"); err != nil {
fmt.Fprintf(os.Stderr, "Error marking code flag as required: %v\n", err)
}
// Password login flags
passwordLoginCmd.Flags().StringVarP(&username, "username", "u", "", "Username for authentication")
passwordLoginCmd.Flags().StringVarP(&password, "password", "p", "", "Password for authentication")
passwordLoginCmd.Flags().StringVarP(&totpCode, "totp", "t", "", "TOTP code (if enabled)")
if err := passwordLoginCmd.MarkFlagRequired("username"); err != nil {
fmt.Fprintf(os.Stderr, "Error marking username flag as required: %v\n", err)
}
if err := passwordLoginCmd.MarkFlagRequired("password"); err != nil {
fmt.Fprintf(os.Stderr, "Error marking password flag as required: %v\n", err)
}
// Token login flags
tokenLoginCmd.Flags().StringVarP(&username, "username", "u", "", "Username for authentication")
tokenLoginCmd.Flags().StringVarP(&token, "token", "t", "", "Authentication token")
if err := tokenLoginCmd.MarkFlagRequired("username"); err != nil {
fmt.Fprintf(os.Stderr, "Error marking username flag as required: %v\n", err)
}
if err := tokenLoginCmd.MarkFlagRequired("token"); err != nil {
fmt.Fprintf(os.Stderr, "Error marking token flag as required: %v\n", err)
}
}
func loginWithPassword() {
serverAddr := viper.GetString("server")
if serverAddr == "" {
serverAddr = "http://localhost:8080"
}
url := fmt.Sprintf("%s/v1/login/password", serverAddr)
loginReq := LoginRequest{
Version: "v1",
Login: LoginParams{
User: username,
Password: password,
TOTPCode: totpCode,
},
}
jsonData, err := json.Marshal(loginReq)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating request: %v\n", err)
os.Exit(1)
}
// Create a context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to create request: %v\n", err)
os.Exit(1)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to send request: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to read response: %v\n", err)
os.Exit(1)
}
if resp.StatusCode != http.StatusOK {
var errResp ErrorResponse
if unmarshalErr := json.Unmarshal(body, &errResp); unmarshalErr == nil {
fmt.Fprintf(os.Stderr, "Error: %s\n", errResp.Error)
} else {
fmt.Fprintf(os.Stderr, "Error: %s\n", resp.Status)
}
os.Exit(1)
}
var tokenResp TokenResponse
if unmarshalErr := json.Unmarshal(body, &tokenResp); unmarshalErr != nil {
fmt.Fprintf(os.Stderr, "Failed to parse response: %v\n", unmarshalErr)
os.Exit(1)
}
// Save the token to the token file
tokenInfo := TokenInfo{
Username: username,
Token: tokenResp.Token,
Expires: tokenResp.Expires,
}
if err := saveToken(tokenInfo); err != nil {
fmt.Fprintf(os.Stderr, "Error saving token: %v\n", err)
// Continue anyway, as we can still display the token
}
fmt.Println("Login successful!")
fmt.Printf("Token: %s\n", tokenResp.Token)
fmt.Printf("Expires: %s\n", time.Unix(tokenResp.Expires, 0).Format(time.RFC3339))
}
func verifyTOTP() {
serverAddr := viper.GetString("server")
if serverAddr == "" {
serverAddr = "http://localhost:8080"
}
url := fmt.Sprintf("%s/v1/login/totp", serverAddr)
totpReq := TOTPVerifyRequest{
Version: "v1",
Username: username,
TOTPCode: totpCode,
}
jsonData, err := json.Marshal(totpReq)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating request: %v\n", err)
os.Exit(1)
}
// Create a context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to create request: %v\n", err)
os.Exit(1)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to send request: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to read response: %v\n", err)
os.Exit(1)
}
if resp.StatusCode != http.StatusOK {
var errResp ErrorResponse
if unmarshalErr := json.Unmarshal(body, &errResp); unmarshalErr == nil {
fmt.Fprintf(os.Stderr, "Error: %s\n", errResp.Error)
} else {
fmt.Fprintf(os.Stderr, "Error: %s\n", resp.Status)
}
os.Exit(1)
}
var tokenResp TokenResponse
if unmarshalErr := json.Unmarshal(body, &tokenResp); unmarshalErr != nil {
fmt.Fprintf(os.Stderr, "Failed to parse response: %v\n", unmarshalErr)
os.Exit(1)
}
// Save the token to the token file
tokenInfo := TokenInfo{
Username: username,
Token: tokenResp.Token,
Expires: tokenResp.Expires,
}
if err := saveToken(tokenInfo); err != nil {
fmt.Fprintf(os.Stderr, "Error saving token: %v\n", err)
// Continue anyway, as we can still display the token
}
fmt.Println("TOTP verification successful!")
fmt.Printf("Token: %s\n", tokenResp.Token)
fmt.Printf("Expires: %s\n", time.Unix(tokenResp.Expires, 0).Format(time.RFC3339))
}
func loginWithToken() {
serverAddr := viper.GetString("server")
if serverAddr == "" {
serverAddr = "http://localhost:8080"
}
url := fmt.Sprintf("%s/v1/login/token", serverAddr)
loginReq := LoginRequest{
Version: "v1",
Login: LoginParams{
User: username,
Token: token,
},
}
jsonData, err := json.Marshal(loginReq)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating request: %v\n", err)
os.Exit(1)
}
// Create a context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to create request: %v\n", err)
os.Exit(1)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to send request: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to read response: %v\n", err)
os.Exit(1)
}
if resp.StatusCode != http.StatusOK {
var errResp ErrorResponse
if unmarshalErr := json.Unmarshal(body, &errResp); unmarshalErr == nil {
fmt.Fprintf(os.Stderr, "Error: %s\n", errResp.Error)
} else {
fmt.Fprintf(os.Stderr, "Error: %s\n", resp.Status)
}
os.Exit(1)
}
var tokenResp TokenResponse
if unmarshalErr := json.Unmarshal(body, &tokenResp); unmarshalErr != nil {
fmt.Fprintf(os.Stderr, "Failed to parse response: %v\n", unmarshalErr)
os.Exit(1)
}
// Save the token to the token file
tokenInfo := TokenInfo{
Username: username,
Token: tokenResp.Token,
Expires: tokenResp.Expires,
}
if err := saveToken(tokenInfo); err != nil {
fmt.Fprintf(os.Stderr, "Error saving token: %v\n", err)
// Continue anyway, as we can still display the token
}
fmt.Println("Token login successful!")
fmt.Printf("Token: %s\n", tokenResp.Token)
fmt.Printf("Expires: %s\n", time.Unix(tokenResp.Expires, 0).Format(time.RFC3339))
}

View File

@@ -1,13 +0,0 @@
package main
import (
"fmt"
"os"
)
func main() {
if err := Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

View File

@@ -1,78 +0,0 @@
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var (
cfgFile string
serverAddr string
tokenFile string
rootCmd = &cobra.Command{
Use: "mcias-client",
Short: "MCIAS Client - Command line client for the Metacircular Identity and Access System",
Long: `MCIAS Client is a command line tool for interacting with the MCIAS server.
It provides access to the MCIAS API endpoints for authentication and resource access.
It currently supports the following operations:
1. User password authentication
2. User token authentication
3. Database credential retrieval`,
}
)
func Execute() error {
return rootCmd.Execute()
}
// setupRootCommand initializes the root command and its flags
func setupRootCommand() {
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.mcias-client.yaml)")
rootCmd.PersistentFlags().StringVar(&serverAddr, "server", "http://localhost:8080", "MCIAS server address")
rootCmd.PersistentFlags().StringVar(&tokenFile, "token-file", "", "File to store authentication token (default is $HOME/.mcias-token)")
if err := viper.BindPFlag("server", rootCmd.PersistentFlags().Lookup("server")); err != nil {
fmt.Fprintf(os.Stderr, "Error binding server flag: %v\n", err)
}
if err := viper.BindPFlag("token-file", rootCmd.PersistentFlags().Lookup("token-file")); err != nil {
fmt.Fprintf(os.Stderr, "Error binding token-file flag: %v\n", err)
}
}
func initConfig() {
if cfgFile != "" {
viper.SetConfigFile(cfgFile)
} else {
home, err := os.UserHomeDir()
cobra.CheckErr(err)
viper.AddConfigPath(home)
viper.SetConfigType("yaml")
viper.SetConfigName(".mcias-client")
}
viper.AutomaticEnv()
if err := viper.ReadInConfig(); err == nil {
fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
}
// Set default token file if not specified
if viper.GetString("token-file") == "" {
home, err := os.UserHomeDir()
if err == nil {
viper.Set("token-file", fmt.Sprintf("%s/.mcias-token", home))
}
}
}
func init() {
setupRootCommand()
}

View File

@@ -1,63 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"os"
"time"
"github.com/spf13/viper"
)
// loadToken loads the token from the token file
func loadToken() (*TokenInfo, error) {
tokenFilePath := viper.GetString("token-file")
if tokenFilePath == "" {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("error getting home directory: %w", err)
}
tokenFilePath = fmt.Sprintf("%s/.mcias-token", home)
}
data, err := os.ReadFile(tokenFilePath)
if err != nil {
return nil, fmt.Errorf("error reading token file: %w", err)
}
var tokenInfo TokenInfo
if err := json.Unmarshal(data, &tokenInfo); err != nil {
return nil, fmt.Errorf("error parsing token file: %w", err)
}
// Check if token is expired
if tokenInfo.Expires > 0 && tokenInfo.Expires < time.Now().Unix() {
return nil, fmt.Errorf("token has expired, please login again")
}
return &tokenInfo, nil
}
// saveToken saves the token to the token file
func saveToken(tokenInfo TokenInfo) error {
tokenFilePath := viper.GetString("token-file")
if tokenFilePath == "" {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("error getting home directory: %w", err)
}
tokenFilePath = fmt.Sprintf("%s/.mcias-token", home)
}
jsonData, err := json.Marshal(tokenInfo)
if err != nil {
return fmt.Errorf("error encoding token: %w", err)
}
if err := os.WriteFile(tokenFilePath, jsonData, 0600); err != nil {
return fmt.Errorf("error saving token to file: %w", err)
}
fmt.Printf("Token saved to %s\n", tokenFilePath)
return nil
}

View File

@@ -1,149 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var (
dbUsername string
dbToken string
)
type DatabaseCredentials struct {
Host string `json:"host"`
Port int `json:"port"`
Name string `json:"name"`
User string `json:"user"`
Password string `json:"password"`
}
type ErrorResponse struct {
Error string `json:"error"`
}
var databaseCmd = &cobra.Command{
Use: "database",
Short: "Manage database credentials",
Long: `Commands for managing database credentials in the MCIAS system.`,
}
var addDBUserCmd = &cobra.Command{
Use: "add-user [database-id] [user-id]",
Short: "Associate a user with a database",
Long: `Associate a user with a database, allowing them to read its credentials.`,
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
addUserToDatabase(args[0], args[1])
},
}
var removeDBUserCmd = &cobra.Command{
Use: "remove-user [database-id] [user-id]",
Short: "Remove a user's association with a database",
Long: `Remove a user's association with a database, preventing them from reading its credentials.`,
Args: cobra.ExactArgs(2),
Run: func(_ *cobra.Command, args []string) {
removeUserFromDatabase(args[0], args[1])
},
}
var listDBUsersCmd = &cobra.Command{
Use: "list-users [database-id]",
Short: "List users associated with a database",
Long: `List all users who have access to read the credentials of a specific database.`,
Args: cobra.ExactArgs(1),
Run: func(_ *cobra.Command, args []string) {
listDatabaseUsers(args[0])
},
}
var getCredentialsCmd = &cobra.Command{
Use: "credentials",
Short: "Get database credentials",
Long: `Retrieve database credentials from the MCIAS system.
This command requires authentication with a username and token.`,
Run: func(_ *cobra.Command, args []string) {
getCredentials()
},
}
func init() {
rootCmd.AddCommand(databaseCmd)
databaseCmd.AddCommand(getCredentialsCmd)
databaseCmd.AddCommand(addDBUserCmd)
databaseCmd.AddCommand(removeDBUserCmd)
databaseCmd.AddCommand(listDBUsersCmd)
getCredentialsCmd.Flags().StringVarP(&dbUsername, "username", "u", "", "Username for authentication")
getCredentialsCmd.Flags().StringVarP(&dbToken, "token", "t", "", "Authentication token")
if err := getCredentialsCmd.MarkFlagRequired("username"); err != nil {
fmt.Fprintf(os.Stderr, "Error marking username flag as required: %v\n", err)
}
if err := getCredentialsCmd.MarkFlagRequired("token"); err != nil {
fmt.Fprintf(os.Stderr, "Error marking token flag as required: %v\n", err)
}
}
func getCredentials() {
logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags)
serverAddr := viper.GetString("server")
if serverAddr == "" {
serverAddr = "http://localhost:8080"
}
url := fmt.Sprintf("%s/v1/database/credentials?username=%s", serverAddr, dbUsername)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
logger.Fatalf("Failed to create request: %v", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", dbToken))
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
logger.Fatalf("Failed to send request: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
logger.Fatalf("Failed to read response: %v", err)
}
if resp.StatusCode != http.StatusOK {
var errResp ErrorResponse
if unmarshalErr := json.Unmarshal(body, &errResp); unmarshalErr == nil {
logger.Fatalf("Error: %s", errResp.Error)
} else {
logger.Fatalf("Error: %s", resp.Status)
}
}
var creds DatabaseCredentials
if unmarshalErr := json.Unmarshal(body, &creds); unmarshalErr != nil {
logger.Fatalf("Failed to parse response: %v", unmarshalErr)
}
fmt.Println("Database Credentials:")
fmt.Printf("Host: %s\n", creds.Host)
fmt.Printf("Port: %d\n", creds.Port)
fmt.Printf("Name: %s\n", creds.Name)
fmt.Printf("User: %s\n", creds.User)
fmt.Printf("Password: %s\n", creds.Password)
}

View File

@@ -1,321 +0,0 @@
package main
import (
"database/sql"
"fmt"
"log"
"os"
"github.com/Masterminds/squirrel"
"github.com/oklog/ulid/v2"
"github.com/spf13/cobra"
)
var (
dbUserCmd = &cobra.Command{
Use: "db-user",
Short: "Manage database user associations",
Long: `Commands for managing which users can access which database credentials.`,
}
addDbUserCmd = &cobra.Command{
Use: "add [database-id] [user-id]",
Short: "Associate a user with a database",
Long: `Associate a user with a database, allowing them to read its credentials.`,
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
addUserToDatabase(args[0], args[1])
},
}
removeDbUserCmd = &cobra.Command{
Use: "remove [database-id] [user-id]",
Short: "Remove a user's association with a database",
Long: `Remove a user's association with a database, preventing them from reading its credentials.`,
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
removeUserFromDatabase(args[0], args[1])
},
}
listDbUsersCmd = &cobra.Command{
Use: "list [database-id]",
Short: "List users associated with a database",
Long: `List all users who have access to read the credentials of a specific database.`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
listDatabaseUsers(args[0])
},
}
listUserDbsCmd = &cobra.Command{
Use: "list-user-dbs [user-id]",
Short: "List databases a user can access",
Long: `List all databases that a specific user has access to.`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
listUserDatabases(args[0])
},
}
)
// nolint:gochecknoinits // This is a standard pattern in Cobra applications
func init() {
rootCmd.AddCommand(dbUserCmd)
dbUserCmd.AddCommand(addDbUserCmd)
dbUserCmd.AddCommand(removeDbUserCmd)
dbUserCmd.AddCommand(listDbUsersCmd)
dbUserCmd.AddCommand(listUserDbsCmd)
}
// addUserToDatabase associates a user with a database, allowing them to read its credentials
func addUserToDatabase(databaseID, userID string) {
logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags)
// Open the database
db, err := openDatabase(dbPath)
if err != nil {
logger.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
// Check if the database exists
exists, err := checkDatabaseExists(db, databaseID)
if err != nil {
logger.Fatalf("Failed to check if database exists: %v", err)
}
if !exists {
logger.Fatalf("Database with ID %s does not exist", databaseID)
}
// Check if the user exists
exists, err = checkUserExists(db, userID)
if err != nil {
logger.Fatalf("Failed to check if user exists: %v", err)
}
if !exists {
logger.Fatalf("User with ID %s does not exist", userID)
}
// Check if the association already exists
exists, err = checkAssociationExists(db, databaseID, userID)
if err != nil {
logger.Fatalf("Failed to check if association exists: %v", err)
}
if exists {
logger.Printf("User %s already has access to database %s", userID, databaseID)
return
}
// Create a new association
id := ulid.Make().String()
query, args, err := squirrel.Insert("database_users").
Columns("id", "db_id", "uid").
Values(id, databaseID, userID).
ToSql()
if err != nil {
logger.Fatalf("Failed to build query: %v", err)
}
_, err = db.Exec(query, args...)
if err != nil {
logger.Fatalf("Failed to add user to database: %v", err)
}
logger.Printf("User %s now has access to database %s", userID, databaseID)
}
// removeUserFromDatabase removes a user's association with a database
func removeUserFromDatabase(databaseID, userID string) {
logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags)
// Open the database
db, err := openDatabase(dbPath)
if err != nil {
logger.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
// Check if the association exists
exists, err := checkAssociationExists(db, databaseID, userID)
if err != nil {
logger.Fatalf("Failed to check if association exists: %v", err)
}
if !exists {
logger.Printf("User %s does not have access to database %s", userID, databaseID)
return
}
// Remove the association
query, args, err := squirrel.Delete("database_users").
Where(squirrel.Eq{"db_id": databaseID, "uid": userID}).
ToSql()
if err != nil {
logger.Fatalf("Failed to build query: %v", err)
}
_, err = db.Exec(query, args...)
if err != nil {
logger.Fatalf("Failed to remove user from database: %v", err)
}
logger.Printf("User %s no longer has access to database %s", userID, databaseID)
}
// listDatabaseUsers lists all users who have access to a specific database
func listDatabaseUsers(databaseID string) {
logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags)
// Open the database
db, err := openDatabase(dbPath)
if err != nil {
logger.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
// Check if the database exists
exists, err := checkDatabaseExists(db, databaseID)
if err != nil {
logger.Fatalf("Failed to check if database exists: %v", err)
}
if !exists {
logger.Fatalf("Database with ID %s does not exist", databaseID)
}
// Get the database name for display
var dbName string
err = db.QueryRow("SELECT name FROM database WHERE id = ?", databaseID).Scan(&dbName)
if err != nil {
logger.Fatalf("Failed to get database name: %v", err)
}
// Query all users who have access to this database
query, args, err := squirrel.Select("u.id", "u.user").
From("users u").
Join("database_users du ON u.id = du.uid").
Where(squirrel.Eq{"du.db_id": databaseID}).
OrderBy("u.user").
ToSql()
if err != nil {
logger.Fatalf("Failed to build query: %v", err)
}
rows, err := db.Query(query, args...)
if err != nil {
logger.Fatalf("Failed to query users: %v", err)
}
defer rows.Close()
fmt.Printf("Users with access to database '%s' (ID: %s):\n\n", dbName, databaseID)
count := 0
for rows.Next() {
var id, username string
if err := rows.Scan(&id, &username); err != nil {
logger.Fatalf("Failed to scan row: %v", err)
}
fmt.Printf(" %s (ID: %s)\n", username, id)
count++
}
if count == 0 {
fmt.Println(" No users have access to this database")
} else {
fmt.Printf("\nTotal: %d user(s)\n", count)
}
}
// listUserDatabases lists all databases a specific user has access to
func listUserDatabases(userID string) {
logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags)
// Open the database
db, err := openDatabase(dbPath)
if err != nil {
logger.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
// Check if the user exists
exists, err := checkUserExists(db, userID)
if err != nil {
logger.Fatalf("Failed to check if user exists: %v", err)
}
if !exists {
logger.Fatalf("User with ID %s does not exist", userID)
}
// Get the username for display
var username string
err = db.QueryRow("SELECT user FROM users WHERE id = ?", userID).Scan(&username)
if err != nil {
logger.Fatalf("Failed to get username: %v", err)
}
// Query all databases this user has access to
query, args, err := squirrel.Select("d.id", "d.name", "d.host").
From("database d").
Join("database_users du ON d.id = du.db_id").
Where(squirrel.Eq{"du.uid": userID}).
OrderBy("d.name").
ToSql()
if err != nil {
logger.Fatalf("Failed to build query: %v", err)
}
rows, err := db.Query(query, args...)
if err != nil {
logger.Fatalf("Failed to query databases: %v", err)
}
defer rows.Close()
fmt.Printf("Databases accessible by user '%s' (ID: %s):\n\n", username, userID)
count := 0
for rows.Next() {
var id, name, host string
if err := rows.Scan(&id, &name, &host); err != nil {
logger.Fatalf("Failed to scan row: %v", err)
}
fmt.Printf(" %s (%s) (ID: %s)\n", name, host, id)
count++
}
if count == 0 {
fmt.Println(" This user does not have access to any databases")
} else {
fmt.Printf("\nTotal: %d database(s)\n", count)
}
}
// Helper functions
// checkDatabaseExists checks if a database with the given ID exists
func checkDatabaseExists(db *sql.DB, databaseID string) (bool, error) {
var count int
err := db.QueryRow("SELECT COUNT(*) FROM database WHERE id = ?", databaseID).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
// checkUserExists checks if a user with the given ID exists
func checkUserExists(db *sql.DB, userID string) (bool, error) {
var count int
err := db.QueryRow("SELECT COUNT(*) FROM users WHERE id = ?", userID).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
// checkAssociationExists checks if a user is already associated with a database
func checkAssociationExists(db *sql.DB, databaseID, userID string) (bool, error) {
var count int
err := db.QueryRow("SELECT COUNT(*) FROM database_users WHERE db_id = ? AND uid = ?", databaseID, userID).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}

View File

@@ -1,68 +0,0 @@
package main
import (
"database/sql"
"log"
"os"
"git.wntrmute.dev/kyle/mcias/database"
_ "github.com/mattn/go-sqlite3"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var (
schemaFile string
)
var initCmd = &cobra.Command{
Use: "init",
Short: "Initialize the MCIAS database",
Long: `Initialize the MCIAS database with the default schema or a custom schema file.
This command will create a new database file if it doesn't exist,
or initialize an existing database.`,
Run: func(cmd *cobra.Command, args []string) {
initializeDatabase()
},
}
func init() {
rootCmd.AddCommand(initCmd)
initCmd.Flags().StringVarP(&schemaFile, "schema", "s", "", "Path to a custom schema file (default: embedded schema)")
}
func initializeDatabase() {
dbPath := viper.GetString("db")
logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags)
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
logger.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
var schemaSQL string
if schemaFile != "" {
// Use custom schema file if provided
schemaBytes, err := os.ReadFile(schemaFile)
if err != nil {
logger.Fatalf("Failed to read custom schema file: %v", err)
}
schemaSQL = string(schemaBytes)
} else {
// Use embedded default schema
var err error
schemaSQL, err = database.DefaultSchema()
if err != nil {
logger.Fatalf("Failed to load default schema: %v", err)
}
}
_, err = db.Exec(schemaSQL)
if err != nil {
logger.Fatalf("Failed to initialize database: %v", err)
}
logger.Println("Database initialized successfully")
}

View File

@@ -1,13 +0,0 @@
package main
import (
"fmt"
"os"
)
func main() {
if err := Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

View File

@@ -1,174 +0,0 @@
package main
import (
"database/sql"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/sqlite3"
_ "github.com/golang-migrate/migrate/v4/source/file"
_ "github.com/mattn/go-sqlite3"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var (
migrationsDir string
steps int
)
var migrateCmd = &cobra.Command{
Use: "migrate",
Short: "Manage database migrations",
Long: `Commands for managing database migrations in the MCIAS system.`,
}
var migrateUpCmd = &cobra.Command{
Use: "up [steps]",
Short: "Apply migrations",
Long: `Apply all or a specific number of migrations.
If steps is not provided, all pending migrations will be applied.`,
Run: func(cmd *cobra.Command, args []string) {
runMigration("up", steps)
},
}
var migrateDownCmd = &cobra.Command{
Use: "down [steps]",
Short: "Revert migrations",
Long: `Revert all or a specific number of migrations.
If steps is not provided, all applied migrations will be reverted.`,
Run: func(cmd *cobra.Command, args []string) {
runMigration("down", steps)
},
}
var migrateVersionCmd = &cobra.Command{
Use: "version",
Short: "Show current migration version",
Long: `Display the current migration version of the database.`,
Run: func(cmd *cobra.Command, args []string) {
showMigrationVersion()
},
}
func init() {
rootCmd.AddCommand(migrateCmd)
migrateCmd.AddCommand(migrateUpCmd)
migrateCmd.AddCommand(migrateDownCmd)
migrateCmd.AddCommand(migrateVersionCmd)
migrateCmd.PersistentFlags().StringVarP(&migrationsDir, "migrations", "m", "database/migrations", "Directory containing migration files")
migrateCmd.PersistentFlags().IntVarP(&steps, "steps", "s", 0, "Number of migrations to apply or revert (0 means all)")
}
func runMigration(direction string, steps int) {
dbPath := viper.GetString("db")
logger := log.New(os.Stdout, "MCIAS Migration: ", log.LstdFlags)
absPath, err := filepath.Abs(migrationsDir)
if err != nil {
logger.Fatalf("Failed to get absolute path for migrations directory: %v", err)
}
if _, err := os.Stat(absPath); os.IsNotExist(err) {
logger.Fatalf("Migrations directory does not exist: %s", absPath)
}
db, err := openDatabase(dbPath)
if err != nil {
logger.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
driver, err := sqlite3.WithInstance(db, &sqlite3.Config{})
if err != nil {
logger.Fatalf("Failed to create migration driver: %v", err)
}
m, err := migrate.NewWithDatabaseInstance(
fmt.Sprintf("file://%s", absPath),
"sqlite3", driver)
if err != nil {
logger.Fatalf("Failed to create migration instance: %v", err)
}
if direction == "up" {
if steps > 0 {
err = m.Steps(steps)
} else {
err = m.Up()
}
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
logger.Fatalf("Failed to apply migrations: %v", err)
}
logger.Println("Migrations applied successfully")
} else if direction == "down" {
if steps > 0 {
err = m.Steps(-steps)
} else {
err = m.Down()
}
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
logger.Fatalf("Failed to revert migrations: %v", err)
}
logger.Println("Migrations reverted successfully")
}
}
func showMigrationVersion() {
dbPath := viper.GetString("db")
logger := log.New(os.Stdout, "MCIAS Migration: ", log.LstdFlags)
db, err := openDatabase(dbPath)
if err != nil {
logger.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
driver, err := sqlite3.WithInstance(db, &sqlite3.Config{})
if err != nil {
logger.Fatalf("Failed to create migration driver: %v", err)
}
absPath, err := filepath.Abs(migrationsDir)
if err != nil {
logger.Fatalf("Failed to get absolute path for migrations directory: %v", err)
}
m, err := migrate.NewWithDatabaseInstance(
fmt.Sprintf("file://%s", absPath),
"sqlite3", driver)
if err != nil {
logger.Fatalf("Failed to create migration instance: %v", err)
}
version, dirty, err := m.Version()
if err != nil {
if errors.Is(err, migrate.ErrNilVersion) {
logger.Println("No migrations have been applied yet")
return
}
logger.Fatalf("Failed to get migration version: %v", err)
}
logger.Printf("Current migration version: %d (dirty: %t)", version, dirty)
}
func openDatabase(dbPath string) (*sql.DB, error) {
dbDir := filepath.Dir(dbPath)
if err := os.MkdirAll(dbDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create database directory: %w", err)
}
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
return db, nil
}

View File

@@ -1,230 +0,0 @@
package main
import (
"database/sql"
"errors"
"fmt"
"log"
"os"
"strings"
"github.com/oklog/ulid/v2"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var (
permissionRole string
permissionResource string
permissionAction string
)
var permissionCmd = &cobra.Command{
Use: "permission",
Short: "Manage permissions",
Long: `Commands for managing permissions in the MCIAS system.`,
}
var listPermissionsCmd = &cobra.Command{
Use: "list",
Short: "List all permissions",
Long: `List all permissions in the MCIAS system.`,
Run: func(cmd *cobra.Command, args []string) {
listPermissions()
},
}
var grantPermissionCmd = &cobra.Command{
Use: "grant",
Short: "Grant a permission to a role",
Long: `Grant a permission to a role in the MCIAS system.`,
Run: func(cmd *cobra.Command, args []string) {
grantPermission()
},
}
var revokePermissionCmd = &cobra.Command{
Use: "revoke",
Short: "Revoke a permission from a role",
Long: `Revoke a permission from a role in the MCIAS system.`,
Run: func(cmd *cobra.Command, args []string) {
revokePermission()
},
}
// nolint:gochecknoinits // This is a standard pattern in Cobra applications
func init() {
rootCmd.AddCommand(permissionCmd)
permissionCmd.AddCommand(listPermissionsCmd)
permissionCmd.AddCommand(grantPermissionCmd)
permissionCmd.AddCommand(revokePermissionCmd)
grantPermissionCmd.Flags().StringVar(&permissionRole, "role", "", "Name of the role to grant the permission to")
grantPermissionCmd.Flags().StringVar(&permissionResource, "resource", "", "Resource for the permission")
grantPermissionCmd.Flags().StringVar(&permissionAction, "action", "", "Action for the permission")
if err := grantPermissionCmd.MarkFlagRequired("role"); err != nil {
fmt.Fprintf(os.Stderr, "Error marking role flag as required: %v\n", err)
}
if err := grantPermissionCmd.MarkFlagRequired("resource"); err != nil {
fmt.Fprintf(os.Stderr, "Error marking resource flag as required: %v\n", err)
}
if err := grantPermissionCmd.MarkFlagRequired("action"); err != nil {
fmt.Fprintf(os.Stderr, "Error marking action flag as required: %v\n", err)
}
revokePermissionCmd.Flags().StringVar(&permissionRole, "role", "", "Name of the role to revoke the permission from")
revokePermissionCmd.Flags().StringVar(&permissionResource, "resource", "", "Resource for the permission")
revokePermissionCmd.Flags().StringVar(&permissionAction, "action", "", "Action for the permission")
if err := revokePermissionCmd.MarkFlagRequired("role"); err != nil {
fmt.Fprintf(os.Stderr, "Error marking role flag as required: %v\n", err)
}
if err := revokePermissionCmd.MarkFlagRequired("resource"); err != nil {
fmt.Fprintf(os.Stderr, "Error marking resource flag as required: %v\n", err)
}
if err := revokePermissionCmd.MarkFlagRequired("action"); err != nil {
fmt.Fprintf(os.Stderr, "Error marking action flag as required: %v\n", err)
}
}
func listPermissions() {
dbPath := viper.GetString("db")
logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags)
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
logger.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
rows, err := db.Query("SELECT id, resource, action, description FROM permissions ORDER BY resource, action")
if err != nil {
logger.Fatalf("Failed to query permissions: %v", err)
}
defer rows.Close()
fmt.Printf("%-24s %-20s %-15s %-30s\n", "ID", "RESOURCE", "ACTION", "DESCRIPTION")
fmt.Println(strings.Repeat("-", 90))
for rows.Next() {
var id, resource, action, description string
if scanErr := rows.Scan(&id, &resource, &action, &description); scanErr != nil {
logger.Fatalf("Failed to scan permission row: %v", scanErr)
}
fmt.Printf("%-24s %-20s %-15s %-30s\n", id, resource, action, description)
}
if rowErr := rows.Err(); rowErr != nil {
logger.Fatalf("Error iterating permission rows: %v", rowErr)
}
}
func grantPermission() {
dbPath := viper.GetString("db")
logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags)
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
logger.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
// Get role ID
var roleID string
err = db.QueryRow("SELECT id FROM roles WHERE role = ?", permissionRole).Scan(&roleID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
logger.Fatalf("Role %s not found", permissionRole)
}
logger.Fatalf("Failed to get role ID: %v", err)
}
// Get permission ID
var permissionID string
err = db.QueryRow("SELECT id FROM permissions WHERE resource = ? AND action = ?",
permissionResource, permissionAction).Scan(&permissionID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
logger.Fatalf("Permission with resource '%s' and action '%s' not found",
permissionResource, permissionAction)
}
logger.Fatalf("Failed to get permission ID: %v", err)
}
// Check if role already has this permission
var count int
err = db.QueryRow("SELECT COUNT(*) FROM role_permissions WHERE rid = ? AND pid = ?",
roleID, permissionID).Scan(&count)
if err != nil {
logger.Fatalf("Failed to check if role has permission: %v", err)
}
if count > 0 {
logger.Fatalf("Role %s already has permission %s:%s",
permissionRole, permissionResource, permissionAction)
}
// Generate a new ID for the role-permission relationship
id := ulid.Make().String()
// Grant permission to role
_, err = db.Exec("INSERT INTO role_permissions (id, rid, pid) VALUES (?, ?, ?)",
id, roleID, permissionID)
if err != nil {
logger.Fatalf("Failed to grant permission: %v", err)
}
fmt.Printf("Permission %s:%s granted to role %s successfully\n",
permissionResource, permissionAction, permissionRole)
}
func revokePermission() {
dbPath := viper.GetString("db")
logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags)
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
logger.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
// Get role ID
var roleID string
err = db.QueryRow("SELECT id FROM roles WHERE role = ?", permissionRole).Scan(&roleID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
logger.Fatalf("Role %s not found", permissionRole)
}
logger.Fatalf("Failed to get role ID: %v", err)
}
// Get permission ID
var permissionID string
err = db.QueryRow("SELECT id FROM permissions WHERE resource = ? AND action = ?",
permissionResource, permissionAction).Scan(&permissionID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
logger.Fatalf("Permission with resource '%s' and action '%s' not found",
permissionResource, permissionAction)
}
logger.Fatalf("Failed to get permission ID: %v", err)
}
// Check if role has this permission
var count int
err = db.QueryRow("SELECT COUNT(*) FROM role_permissions WHERE rid = ? AND pid = ?",
roleID, permissionID).Scan(&count)
if err != nil {
logger.Fatalf("Failed to check if role has permission: %v", err)
}
if count == 0 {
logger.Fatalf("Role %s does not have permission %s:%s",
permissionRole, permissionResource, permissionAction)
}
// Revoke permission from role
_, err = db.Exec("DELETE FROM role_permissions WHERE rid = ? AND pid = ?", roleID, permissionID)
if err != nil {
logger.Fatalf("Failed to revoke permission: %v", err)
}
fmt.Printf("Permission %s:%s revoked from role %s successfully\n",
permissionResource, permissionAction, permissionRole)
}

View File

@@ -1,256 +0,0 @@
package main
import (
"database/sql"
"errors"
"fmt"
"log"
"os"
"strings"
"github.com/oklog/ulid/v2"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var (
roleName string
roleUser string
)
var roleCmd = &cobra.Command{
Use: "role",
Short: "Manage roles",
Long: `Commands for managing roles in the MCIAS system.`,
}
var addRoleCmd = &cobra.Command{
Use: "add",
Short: "Add a new role",
Long: `Add a new role to the MCIAS system.`,
Run: func(cmd *cobra.Command, args []string) {
addRole()
},
}
var listRolesCmd = &cobra.Command{
Use: "list",
Short: "List all roles",
Long: `List all roles in the MCIAS system.`,
Run: func(cmd *cobra.Command, args []string) {
listRoles()
},
}
var assignRoleCmd = &cobra.Command{
Use: "assign",
Short: "Assign a role to a user",
Long: `Assign a role to a user in the MCIAS system.`,
Run: func(cmd *cobra.Command, args []string) {
assignRole()
},
}
var revokeRoleCmd = &cobra.Command{
Use: "revoke",
Short: "Revoke a role from a user",
Long: `Revoke a role from a user in the MCIAS system.`,
Run: func(cmd *cobra.Command, args []string) {
revokeRole()
},
}
func init() {
rootCmd.AddCommand(roleCmd)
roleCmd.AddCommand(addRoleCmd)
roleCmd.AddCommand(listRolesCmd)
roleCmd.AddCommand(assignRoleCmd)
roleCmd.AddCommand(revokeRoleCmd)
addRoleCmd.Flags().StringVar(&roleName, "name", "", "Name of the role")
if err := addRoleCmd.MarkFlagRequired("name"); err != nil {
fmt.Fprintf(os.Stderr, "Error marking name flag as required: %v\n", err)
}
assignRoleCmd.Flags().StringVar(&roleUser, "user", "", "Username to assign the role to")
assignRoleCmd.Flags().StringVar(&roleName, "role", "", "Name of the role to assign")
if err := assignRoleCmd.MarkFlagRequired("user"); err != nil {
fmt.Fprintf(os.Stderr, "Error marking user flag as required: %v\n", err)
}
if err := assignRoleCmd.MarkFlagRequired("role"); err != nil {
fmt.Fprintf(os.Stderr, "Error marking role flag as required: %v\n", err)
}
revokeRoleCmd.Flags().StringVar(&roleUser, "user", "", "Username to revoke the role from")
revokeRoleCmd.Flags().StringVar(&roleName, "role", "", "Name of the role to revoke")
if err := revokeRoleCmd.MarkFlagRequired("user"); err != nil {
fmt.Fprintf(os.Stderr, "Error marking user flag as required: %v\n", err)
}
if err := revokeRoleCmd.MarkFlagRequired("role"); err != nil {
fmt.Fprintf(os.Stderr, "Error marking role flag as required: %v\n", err)
}
}
func addRole() {
dbPath := viper.GetString("db")
logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags)
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
logger.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
// Check if role already exists
var count int
err = db.QueryRow("SELECT COUNT(*) FROM roles WHERE role = ?", roleName).Scan(&count)
if err != nil {
logger.Fatalf("Failed to check if role exists: %v", err)
}
if count > 0 {
logger.Fatalf("Role %s already exists", roleName)
}
// Generate a new ID for the role
id := ulid.Make().String()
// Insert the new role
_, err = db.Exec("INSERT INTO roles (id, role) VALUES (?, ?)", id, roleName)
if err != nil {
logger.Fatalf("Failed to insert role: %v", err)
}
fmt.Printf("Role %s added successfully with ID %s\n", roleName, id)
}
func listRoles() {
dbPath := viper.GetString("db")
logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags)
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
logger.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
rows, err := db.Query("SELECT id, role FROM roles ORDER BY role")
if err != nil {
logger.Fatalf("Failed to query roles: %v", err)
}
defer rows.Close()
fmt.Printf("%-24s %-30s\n", "ID", "ROLE")
fmt.Println(strings.Repeat("-", 55))
for rows.Next() {
var id, role string
if err := rows.Scan(&id, &role); err != nil {
logger.Fatalf("Failed to scan role row: %v", err)
}
fmt.Printf("%-24s %-30s\n", id, role)
}
if err := rows.Err(); err != nil {
logger.Fatalf("Error iterating role rows: %v", err)
}
}
func assignRole() {
dbPath := viper.GetString("db")
logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags)
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
logger.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
// Get user ID
var userID string
err = db.QueryRow("SELECT id FROM users WHERE user = ?", roleUser).Scan(&userID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
logger.Fatalf("User %s not found", roleUser)
}
logger.Fatalf("Failed to get user ID: %v", err)
}
// Get role ID
var roleID string
err = db.QueryRow("SELECT id FROM roles WHERE role = ?", roleName).Scan(&roleID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
logger.Fatalf("Role %s not found", roleName)
}
logger.Fatalf("Failed to get role ID: %v", err)
}
// Check if user already has this role
var count int
err = db.QueryRow("SELECT COUNT(*) FROM user_roles WHERE uid = ? AND rid = ?", userID, roleID).Scan(&count)
if err != nil {
logger.Fatalf("Failed to check if user has role: %v", err)
}
if count > 0 {
logger.Fatalf("User %s already has role %s", roleUser, roleName)
}
// Generate a new ID for the user-role relationship
id := ulid.Make().String()
// Assign role to user
_, err = db.Exec("INSERT INTO user_roles (id, uid, rid) VALUES (?, ?, ?)", id, userID, roleID)
if err != nil {
logger.Fatalf("Failed to assign role: %v", err)
}
fmt.Printf("Role %s assigned to user %s successfully\n", roleName, roleUser)
}
func revokeRole() {
dbPath := viper.GetString("db")
logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags)
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
logger.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
// Get user ID
var userID string
err = db.QueryRow("SELECT id FROM users WHERE user = ?", roleUser).Scan(&userID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
logger.Fatalf("User %s not found", roleUser)
}
logger.Fatalf("Failed to get user ID: %v", err)
}
// Get role ID
var roleID string
err = db.QueryRow("SELECT id FROM roles WHERE role = ?", roleName).Scan(&roleID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
logger.Fatalf("Role %s not found", roleName)
}
logger.Fatalf("Failed to get role ID: %v", err)
}
// Check if user has this role
var count int
err = db.QueryRow("SELECT COUNT(*) FROM user_roles WHERE uid = ? AND rid = ?", userID, roleID).Scan(&count)
if err != nil {
logger.Fatalf("Failed to check if user has role: %v", err)
}
if count == 0 {
logger.Fatalf("User %s does not have role %s", roleUser, roleName)
}
// Revoke role from user
_, err = db.Exec("DELETE FROM user_roles WHERE uid = ? AND rid = ?", userID, roleID)
if err != nil {
logger.Fatalf("Failed to revoke role: %v", err)
}
fmt.Printf("Role %s revoked from user %s successfully\n", roleName, roleUser)
}

View File

@@ -1,71 +0,0 @@
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var (
cfgFile string
dbPath string
addr string
rootCmd = &cobra.Command{
Use: "mcias",
Short: "MCIAS - Metacircular Identity and Access System",
Long: `MCIAS is the metacircular identity and access system,
providing identity and authentication across metacircular projects.
It currently provides the following across metacircular services:
1. User password authentication
2. User token authentication
3. Database credential authentication`,
}
)
func Execute() error {
// Setup commands and flags
setupRootCommand()
setupTOTPCommands()
// The migrate command is already set up in its init function
// Execute the root command
return rootCmd.Execute()
}
// setupRootCommand initializes the root command and its flags
func setupRootCommand() {
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.mcias.yaml)")
rootCmd.PersistentFlags().StringVar(&dbPath, "db", "mcias.db", "Path to SQLite database file")
rootCmd.PersistentFlags().StringVar(&addr, "addr", ":5000", "Address to listen on")
if err := viper.BindPFlag("db", rootCmd.PersistentFlags().Lookup("db")); err != nil {
fmt.Fprintf(os.Stderr, "Error binding db flag: %v\n", err)
}
if err := viper.BindPFlag("addr", rootCmd.PersistentFlags().Lookup("addr")); err != nil {
fmt.Fprintf(os.Stderr, "Error binding addr flag: %v\n", err)
}
}
func initConfig() {
if cfgFile != "" {
viper.SetConfigFile(cfgFile)
} else {
home, err := os.UserHomeDir()
cobra.CheckErr(err)
viper.AddConfigPath(home)
viper.SetConfigType("yaml")
viper.SetConfigName(".mcias")
}
viper.AutomaticEnv()
if err := viper.ReadInConfig(); err == nil {
fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
}
}

View File

@@ -1,46 +0,0 @@
package main
import (
"database/sql"
"log"
"os"
"git.wntrmute.dev/kyle/mcias/api"
_ "github.com/mattn/go-sqlite3"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var serverCmd = &cobra.Command{
Use: "server",
Short: "Start the MCIAS server",
Long: `Start the MCIAS server which provides authentication services.
The server will listen on the specified address and connect to the
specified database.`,
Run: func(cmd *cobra.Command, args []string) {
runServer()
},
}
func init() {
rootCmd.AddCommand(serverCmd)
}
func runServer() {
dbPath := viper.GetString("db")
addr := viper.GetString("addr")
logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags)
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
logger.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
server := api.NewServer(db, logger)
logger.Printf("Starting MCIAS server on %s", addr)
if err := server.Start(addr); err != nil {
logger.Fatalf("Server error: %v", err)
}
}

View File

@@ -1,154 +0,0 @@
package main
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"log"
"os"
"strings"
"time"
_ "github.com/mattn/go-sqlite3"
"github.com/oklog/ulid/v2"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var (
tokenUsername string
tokenDuration int64
)
var tokenCmd = &cobra.Command{
Use: "token",
Short: "Manage tokens",
Long: `Commands for managing authentication tokens in the MCIAS system.`,
}
var addTokenCmd = &cobra.Command{
Use: "add",
Short: "Add a new token for a user",
Long: `Add a new authentication token for a user in the MCIAS system.
This command requires a username and optionally a duration in hours.`,
Run: func(cmd *cobra.Command, args []string) {
addToken()
},
}
var listTokensCmd = &cobra.Command{
Use: "list",
Short: "List all tokens",
Long: `List all authentication tokens in the MCIAS system.`,
Run: func(cmd *cobra.Command, args []string) {
listTokens()
},
}
func init() {
rootCmd.AddCommand(tokenCmd)
tokenCmd.AddCommand(addTokenCmd)
tokenCmd.AddCommand(listTokensCmd)
addTokenCmd.Flags().StringVarP(&tokenUsername, "username", "u", "", "Username to create token for")
addTokenCmd.Flags().Int64VarP(&tokenDuration, "duration", "d", 24, "Token duration in hours (default 24)")
if err := addTokenCmd.MarkFlagRequired("username"); err != nil {
fmt.Fprintf(os.Stderr, "Error marking username flag as required: %v\n", err)
}
}
func addToken() {
dbPath := viper.GetString("db")
logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags)
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
logger.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
var userID string
err = db.QueryRow("SELECT id FROM users WHERE user = ?", tokenUsername).Scan(&userID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
logger.Fatalf("User %s does not exist", tokenUsername)
}
logger.Fatalf("Failed to check if user exists: %v", err)
}
// Generate 16 bytes of random data
tokenBytes := make([]byte, 16)
if _, err := rand.Read(tokenBytes); err != nil {
logger.Fatalf("Failed to generate random token: %v", err)
}
// Hex encode the random bytes to get a 32-character string
token := hex.EncodeToString(tokenBytes)
expires := time.Now().Add(time.Duration(tokenDuration) * time.Hour).Unix()
query := `INSERT INTO tokens (id, uid, token, expires) VALUES (?, ?, ?, ?)`
tokenID := ulid.Make().String()
_, err = db.Exec(query, tokenID, userID, token, expires)
if err != nil {
logger.Fatalf("Failed to insert token into database: %v", err)
}
expiresTime := time.Unix(expires, 0).Format(time.RFC3339)
fmt.Printf("Token created successfully for user %s\n", tokenUsername)
fmt.Printf("Token: %s\n", token)
fmt.Printf("Expires: %s\n", expiresTime)
}
func listTokens() {
dbPath := viper.GetString("db")
logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags)
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
logger.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
query := `
SELECT t.id, t.token, t.expires, u.user
FROM tokens t
JOIN users u ON t.uid = u.id
ORDER BY t.expires DESC
`
rows, err := db.Query(query)
if err != nil {
logger.Fatalf("Failed to query tokens: %v", err)
}
defer rows.Close()
fmt.Printf("%-24s %-30s %-20s %-20s %-10s\n", "ID", "TOKEN", "USERNAME", "EXPIRES", "STATUS")
fmt.Println(strings.Repeat("-", 100))
now := time.Now().Unix()
for rows.Next() {
var id, token, username string
var expires int64
if err := rows.Scan(&id, &token, &expires, &username); err != nil {
logger.Fatalf("Failed to scan token row: %v", err)
}
expiresTime := time.Unix(expires, 0).Format(time.RFC3339)
status := "ACTIVE"
if expires > 0 && expires < now {
status = "EXPIRED"
}
fmt.Printf("%-24s %-30s %-20s %-20s %-10s\n", id, token, username, expiresTime, status)
}
if err := rows.Err(); err != nil {
logger.Fatalf("Error iterating token rows: %v", err)
}
}

View File

@@ -1,272 +0,0 @@
package main
import (
"database/sql"
"errors"
"fmt"
"log"
"os"
"git.wntrmute.dev/kyle/mcias/data"
_ "github.com/mattn/go-sqlite3"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
const (
userQuery = `SELECT id, created, user, password, salt, totp_secret FROM users WHERE user = ?`
)
var (
totpUsername string
totpCode string
qrCodeOutput string
issuer string
)
var totpCmd = &cobra.Command{
Use: "totp",
Short: "Manage TOTP authentication",
Long: `Commands for managing TOTP (Time-based One-Time Password) authentication in the MCIAS system.`,
}
var enableTOTPCmd = &cobra.Command{
Use: "enable",
Short: "Enable TOTP for a user",
Long: `Enable TOTP (Time-based One-Time Password) authentication for a user in the MCIAS system.
This command requires a username.`,
Run: func(cmd *cobra.Command, args []string) {
enableTOTP()
},
}
var validateTOTPCmd = &cobra.Command{
Use: "validate",
Short: "Validate a TOTP code",
Long: `Validate a TOTP code for a user in the MCIAS system.
This command requires a username and a TOTP code.`,
Run: func(cmd *cobra.Command, args []string) {
validateTOTP()
},
}
var addTOTPCmd = &cobra.Command{
Use: "add",
Short: "Add a new TOTP token for a user",
Long: `Add a new TOTP (Time-based One-Time Password) token for a user in the MCIAS system.
This command requires a username. It will emit the secret, and optionally output a QR code image file.`,
Run: func(cmd *cobra.Command, args []string) {
addTOTP()
},
}
func setupTOTPCommands() {
rootCmd.AddCommand(totpCmd)
totpCmd.AddCommand(enableTOTPCmd)
totpCmd.AddCommand(validateTOTPCmd)
totpCmd.AddCommand(addTOTPCmd)
enableTOTPCmd.Flags().StringVarP(&totpUsername, "username", "u", "", "Username to enable TOTP for")
if err := enableTOTPCmd.MarkFlagRequired("username"); err != nil {
fmt.Fprintf(os.Stderr, "Error marking username flag as required: %v\n", err)
}
validateTOTPCmd.Flags().StringVarP(&totpUsername, "username", "u", "", "Username to validate TOTP code for")
validateTOTPCmd.Flags().StringVarP(&totpCode, "code", "c", "", "TOTP code to validate")
if err := validateTOTPCmd.MarkFlagRequired("username"); err != nil {
fmt.Fprintf(os.Stderr, "Error marking username flag as required: %v\n", err)
}
if err := validateTOTPCmd.MarkFlagRequired("code"); err != nil {
fmt.Fprintf(os.Stderr, "Error marking code flag as required: %v\n", err)
}
addTOTPCmd.Flags().StringVarP(&totpUsername, "username", "u", "", "Username to add TOTP token for")
addTOTPCmd.Flags().StringVarP(&qrCodeOutput, "qr-output", "q", "", "Path to save QR code image (optional)")
addTOTPCmd.Flags().StringVarP(&issuer, "issuer", "i", "MCIAS", "Issuer name for TOTP token (optional)")
if err := addTOTPCmd.MarkFlagRequired("username"); err != nil {
fmt.Fprintf(os.Stderr, "Error marking username flag as required: %v\n", err)
}
}
func enableTOTP() {
dbPath := viper.GetString("db")
logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags)
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
logger.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
var userID string
var created int64
var username string
var password, salt []byte
var totpSecret sql.NullString
err = db.QueryRow(userQuery, totpUsername).Scan(&userID, &created, &username, &password, &salt, &totpSecret)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
logger.Fatalf("User %s does not exist", totpUsername)
}
logger.Fatalf("Failed to get user: %v", err)
}
// Check if TOTP is already enabled
if totpSecret.Valid && totpSecret.String != "" {
logger.Fatalf("TOTP is already enabled for user %s", totpUsername)
}
// Create a user object
user := &data.User{
ID: userID,
Created: created,
User: username,
Password: password,
Salt: salt,
}
// Generate a TOTP secret
secret, err := user.GenerateTOTPSecret()
if err != nil {
logger.Fatalf("Failed to generate TOTP secret: %v", err)
}
// Update the user in the database
updateQuery := `UPDATE users SET totp_secret = ? WHERE id = ?`
_, err = db.Exec(updateQuery, secret, userID)
if err != nil {
logger.Fatalf("Failed to update user: %v", err)
}
fmt.Printf("TOTP enabled for user %s\n", totpUsername)
fmt.Printf("Secret: %s\n", secret)
fmt.Println("Please save this secret in your authenticator app.")
fmt.Println("You will need to provide a TOTP code when logging in.")
}
func validateTOTP() {
dbPath := viper.GetString("db")
logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags)
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
logger.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
// Get the user from the database
var userID string
var created int64
var username string
var password, salt []byte
var totpSecret sql.NullString
err = db.QueryRow(userQuery, totpUsername).Scan(&userID, &created, &username, &password, &salt, &totpSecret)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
logger.Fatalf("User %s does not exist", totpUsername)
}
logger.Fatalf("Failed to get user: %v", err)
}
// Check if TOTP is enabled
if !totpSecret.Valid || totpSecret.String == "" {
logger.Fatalf("TOTP is not enabled for user %s", totpUsername)
}
// Create a user object
user := &data.User{
ID: userID,
Created: created,
User: username,
Password: password,
Salt: salt,
TOTPSecret: totpSecret.String,
}
// Validate the TOTP code
valid, err := user.ValidateTOTPCode(totpCode)
if err != nil {
logger.Fatalf("Failed to validate TOTP code: %v", err)
}
// Close the database before potentially exiting
db.Close()
if valid {
fmt.Println("TOTP code is valid")
} else {
fmt.Println("TOTP code is invalid")
os.Exit(1)
}
}
func addTOTP() {
dbPath := viper.GetString("db")
logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags)
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
logger.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
// Get the user from the database
var userID string
var created int64
var username string
var password, salt []byte
var totpSecret sql.NullString
err = db.QueryRow(userQuery, totpUsername).Scan(&userID, &created, &username, &password, &salt, &totpSecret)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
logger.Fatalf("User %s does not exist", totpUsername)
}
logger.Fatalf("Failed to get user: %v", err)
}
// Check if TOTP is already enabled
if totpSecret.Valid && totpSecret.String != "" {
logger.Fatalf("TOTP is already enabled for user %s", totpUsername)
}
// Create a user object
user := &data.User{
ID: userID,
Created: created,
User: username,
Password: password,
Salt: salt,
}
// Generate a TOTP secret
secret, err := user.GenerateTOTPSecret()
if err != nil {
logger.Fatalf("Failed to generate TOTP secret: %v", err)
}
// Update the user in the database
updateQuery := `UPDATE users SET totp_secret = ? WHERE id = ?`
_, err = db.Exec(updateQuery, secret, userID)
if err != nil {
logger.Fatalf("Failed to update user: %v", err)
}
fmt.Printf("TOTP token added for user %s\n", totpUsername)
fmt.Printf("Secret: %s\n", secret)
fmt.Println("Please save this secret in your authenticator app.")
// Generate QR code if output path is specified
if qrCodeOutput != "" {
err = data.GenerateTOTPQRCode(secret, username, issuer, qrCodeOutput)
if err != nil {
logger.Fatalf("Failed to generate QR code: %v", err)
}
fmt.Printf("QR code saved to %s\n", qrCodeOutput)
}
}

View File

@@ -1,134 +0,0 @@
package main
import (
"database/sql"
"fmt"
"log"
"os"
"strings"
"time"
"git.wntrmute.dev/kyle/mcias/data"
_ "github.com/mattn/go-sqlite3"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var (
username string
password string
)
var userCmd = &cobra.Command{
Use: "user",
Short: "Manage users",
Long: `Commands for managing users in the MCIAS system.`,
}
var addUserCmd = &cobra.Command{
Use: "add",
Short: "Add a new user",
Long: `Add a new user to the MCIAS system.
This command requires a username and password.`,
Run: func(cmd *cobra.Command, args []string) {
addUser()
},
}
var listUsersCmd = &cobra.Command{
Use: "list",
Short: "List all users",
Long: `List all users in the MCIAS system.`,
Run: func(cmd *cobra.Command, args []string) {
listUsers()
},
}
func init() {
rootCmd.AddCommand(userCmd)
userCmd.AddCommand(addUserCmd)
userCmd.AddCommand(listUsersCmd)
addUserCmd.Flags().StringVarP(&username, "username", "u", "", "Username for the new user")
addUserCmd.Flags().StringVarP(&password, "password", "p", "", "Password for the new user")
if err := addUserCmd.MarkFlagRequired("username"); err != nil {
fmt.Fprintf(os.Stderr, "Error marking username flag as required: %v\n", err)
}
if err := addUserCmd.MarkFlagRequired("password"); err != nil {
fmt.Fprintf(os.Stderr, "Error marking password flag as required: %v\n", err)
}
}
func addUser() {
dbPath := viper.GetString("db")
logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags)
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
logger.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
var count int
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE user = ?", username).Scan(&count)
if err != nil {
logger.Fatalf("Failed to check if user exists: %v", err)
}
if count > 0 {
logger.Fatalf("User %s already exists", username)
}
user := &data.User{}
login := &data.Login{
User: username,
Password: password,
}
if err := user.Register(login); err != nil {
logger.Fatalf("Failed to register user: %v", err)
}
query := `INSERT INTO users (id, created, user, password, salt) VALUES (?, ?, ?, ?, ?)`
_, err = db.Exec(query, user.ID, user.Created, user.User, user.Password, user.Salt)
if err != nil {
logger.Fatalf("Failed to insert user into database: %v", err)
}
fmt.Printf("User %s added successfully with ID %s\n", user.User, user.ID)
}
func listUsers() {
dbPath := viper.GetString("db")
logger := log.New(os.Stdout, "MCIAS: ", log.LstdFlags)
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
logger.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
rows, err := db.Query("SELECT id, created, user FROM users ORDER BY user")
if err != nil {
logger.Fatalf("Failed to query users: %v", err)
}
defer rows.Close()
fmt.Printf("%-24s %-30s %-20s\n", "ID", "USERNAME", "CREATED")
fmt.Println(strings.Repeat("-", 76))
for rows.Next() {
var id string
var created int64
var username string
if err := rows.Scan(&id, &created, &username); err != nil {
logger.Fatalf("Failed to scan user row: %v", err)
}
createdTime := time.Unix(created, 0).Format(time.RFC3339)
fmt.Printf("%-24s %-30s %-20s\n", id, username, createdTime)
}
if err := rows.Err(); err != nil {
logger.Fatalf("Error iterating user rows: %v", err)
}
}

View File

@@ -1,183 +0,0 @@
package data
import (
"database/sql"
"fmt"
"github.com/oklog/ulid/v2"
)
const (
// Constants for error messages
errScanPermission = "failed to scan permission: %w"
errIteratePermissions = "error iterating permissions: %w"
// Constants for comparison
zeroCount = 0
)
// Permission represents a system permission.
type Permission struct {
ID string
Resource string
Action string
Description string
}
// AuthorizationService provides methods for checking user permissions.
type AuthorizationService struct {
db *sql.DB
}
// NewAuthorizationService creates a new authorization service.
func NewAuthorizationService(db *sql.DB) *AuthorizationService {
return &AuthorizationService{db: db}
}
// UserHasPermission checks if a user has a specific permission for a resource and action
func (a *AuthorizationService) UserHasPermission(userID, resource, action string) (bool, error) {
query := `
SELECT COUNT(*) FROM permissions p
JOIN role_permissions rp ON p.id = rp.pid
JOIN user_roles ur ON rp.rid = ur.rid
WHERE ur.uid = ? AND p.resource = ? AND p.action = ?
`
var count int
err := a.db.QueryRow(query, userID, resource, action).Scan(&count)
if err != nil {
return false, fmt.Errorf("failed to check user permission: %w", err)
}
return count > zeroCount, nil
}
// GetUserPermissions returns all permissions for a user based on their roles.
func (a *AuthorizationService) GetUserPermissions(userID string) ([]Permission, error) {
query := `
SELECT DISTINCT p.id, p.resource, p.action, p.description FROM permissions p
JOIN role_permissions rp ON p.id = rp.pid
JOIN user_roles ur ON rp.rid = ur.rid
WHERE ur.uid = ?
`
rows, err := a.db.Query(query, userID)
if err != nil {
return nil, fmt.Errorf("failed to get user permissions: %w", err)
}
defer rows.Close()
var permissions []Permission
for rows.Next() {
var perm Permission
if scanErr := rows.Scan(&perm.ID, &perm.Resource, &perm.Action, &perm.Description); scanErr != nil {
return nil, fmt.Errorf(errScanPermission, scanErr)
}
permissions = append(permissions, perm)
}
if rowErr := rows.Err(); rowErr != nil {
return nil, fmt.Errorf(errIteratePermissions, rowErr)
}
return permissions, nil
}
// GetRolePermissions returns all permissions for a specific role.
func (a *AuthorizationService) GetRolePermissions(roleID string) ([]Permission, error) {
query := `
SELECT p.id, p.resource, p.action, p.description FROM permissions p
JOIN role_permissions rp ON p.id = rp.pid
WHERE rp.rid = ?
`
rows, err := a.db.Query(query, roleID)
if err != nil {
return nil, fmt.Errorf("failed to get role permissions: %w", err)
}
defer rows.Close()
var permissions []Permission
for rows.Next() {
var perm Permission
if scanErr := rows.Scan(&perm.ID, &perm.Resource, &perm.Action, &perm.Description); scanErr != nil {
return nil, fmt.Errorf(errScanPermission, scanErr)
}
permissions = append(permissions, perm)
}
if rowErr := rows.Err(); rowErr != nil {
return nil, fmt.Errorf(errIteratePermissions, rowErr)
}
return permissions, nil
}
// GrantPermissionToRole grants a permission to a role
func (a *AuthorizationService) GrantPermissionToRole(roleID, permissionID string) error {
// Check if the role-permission relationship already exists
checkQuery := `SELECT COUNT(*) FROM role_permissions WHERE rid = ? AND pid = ?`
var count int
err := a.db.QueryRow(checkQuery, roleID, permissionID).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check role permission: %w", err)
}
if count > 0 {
return nil // Permission already granted
}
// Generate a new ID for the role-permission relationship
id := GenerateID()
// Insert the new role-permission relationship
insertQuery := `INSERT INTO role_permissions (id, rid, pid) VALUES (?, ?, ?)`
_, err = a.db.Exec(insertQuery, id, roleID, permissionID)
if err != nil {
return fmt.Errorf("failed to grant permission to role: %w", err)
}
return nil
}
// RevokePermissionFromRole revokes a permission from a role
func (a *AuthorizationService) RevokePermissionFromRole(roleID, permissionID string) error {
query := `DELETE FROM role_permissions WHERE rid = ? AND pid = ?`
_, err := a.db.Exec(query, roleID, permissionID)
if err != nil {
return fmt.Errorf("failed to revoke permission from role: %w", err)
}
return nil
}
// GetAllPermissions returns all permissions in the system.
func (a *AuthorizationService) GetAllPermissions() ([]Permission, error) {
query := `SELECT id, resource, action, description FROM permissions`
rows, err := a.db.Query(query)
if err != nil {
return nil, fmt.Errorf("failed to get permissions: %w", err)
}
defer rows.Close()
var permissions []Permission
for rows.Next() {
var perm Permission
if scanErr := rows.Scan(&perm.ID, &perm.Resource, &perm.Action, &perm.Description); scanErr != nil {
return nil, fmt.Errorf(errScanPermission, scanErr)
}
permissions = append(permissions, perm)
}
if rowErr := rows.Err(); rowErr != nil {
return nil, fmt.Errorf(errIteratePermissions, rowErr)
}
return permissions, nil
}
// GenerateID generates a unique ID for database records
func GenerateID() string {
return ulid.Make().String()
}

View File

@@ -1,223 +0,0 @@
package data
import (
"database/sql"
"os"
"testing"
_ "github.com/mattn/go-sqlite3"
)
func setupTestDB(t *testing.T) (*sql.DB, func()) {
// Create a temporary database for testing
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("Failed to open in-memory database: %v", err)
}
// Read the schema file
schemaBytes, err := os.ReadFile("../database/schema.sql")
if err != nil {
t.Fatalf("Failed to read schema file: %v", err)
}
schema := string(schemaBytes)
// Execute the schema
_, err = db.Exec(schema)
if err != nil {
t.Fatalf("Failed to execute schema: %v", err)
}
// Create test data
setupTestData(t, db)
// Return the database and a cleanup function
return db, func() {
db.Close()
}
}
func setupTestData(t *testing.T, db *sql.DB) {
// Create test users
_, err := db.Exec(`INSERT INTO users (id, created, user, password, salt) VALUES
('user1', 1622505600, 'testadmin', 'dummy', 'dummy'),
('user2', 1622505600, 'testoperator', 'dummy', 'dummy'),
('user3', 1622505600, 'testuser', 'dummy', 'dummy')`)
if err != nil {
t.Fatalf("Failed to insert test users: %v", err)
}
// Create test roles (these should already exist from schema.sql)
// But we'll check and insert if needed
var count int
err = db.QueryRow("SELECT COUNT(*) FROM roles WHERE role = 'admin'").Scan(&count)
if err != nil {
t.Fatalf("Failed to check roles: %v", err)
}
if count == 0 {
_, err = db.Exec(`INSERT INTO roles (id, role) VALUES
('role_admin', 'admin'),
('role_db_operator', 'db_operator'),
('role_user', 'user')`)
if err != nil {
t.Fatalf("Failed to insert test roles: %v", err)
}
}
// Assign roles to users
_, err = db.Exec(`INSERT INTO user_roles (id, uid, rid) VALUES
('ur1', 'user1', 'role_admin'),
('ur2', 'user2', 'role_db_operator'),
('ur3', 'user3', 'role_user')`)
if err != nil {
t.Fatalf("Failed to assign roles to users: %v", err)
}
}
func TestUserHasPermission(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
authService := NewAuthorizationService(db)
tests := []struct {
name string
userID string
resource string
action string
want bool
}{
{
name: "Admin has database read permission",
userID: "user1",
resource: "database_credentials",
action: "read",
want: true,
},
{
name: "Admin has database write permission",
userID: "user1",
resource: "database_credentials",
action: "write",
want: true,
},
{
name: "DB Operator has database read permission",
userID: "user2",
resource: "database_credentials",
action: "read",
want: true,
},
{
name: "DB Operator does not have database write permission",
userID: "user2",
resource: "database_credentials",
action: "write",
want: false,
},
{
name: "Regular user does not have database read permission",
userID: "user3",
resource: "database_credentials",
action: "read",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := authService.UserHasPermission(tt.userID, tt.resource, tt.action)
if err != nil {
t.Errorf("AuthorizationService.UserHasPermission() error = %v", err)
return
}
if got != tt.want {
t.Errorf("AuthorizationService.UserHasPermission() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetUserPermissions(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
authService := NewAuthorizationService(db)
t.Run("Admin has all permissions", func(t *testing.T) {
permissions, err := authService.GetUserPermissions("user1")
if err != nil {
t.Errorf("AuthorizationService.GetUserPermissions() error = %v", err)
return
}
// Admin should have 4 permissions
if len(permissions) != 4 {
t.Errorf("Admin should have 4 permissions, got %d", len(permissions))
}
// Check for specific permissions
hasDBRead := false
hasDBWrite := false
for _, p := range permissions {
if p.Resource == "database_credentials" && p.Action == "read" {
hasDBRead = true
}
if p.Resource == "database_credentials" && p.Action == "write" {
hasDBWrite = true
}
}
if !hasDBRead {
t.Errorf("Admin should have database_credentials:read permission")
}
if !hasDBWrite {
t.Errorf("Admin should have database_credentials:write permission")
}
})
t.Run("DB Operator has limited permissions", func(t *testing.T) {
permissions, err := authService.GetUserPermissions("user2")
if err != nil {
t.Errorf("AuthorizationService.GetUserPermissions() error = %v", err)
return
}
// DB Operator should have 1 permission
if len(permissions) != 1 {
t.Errorf("DB Operator should have 1 permission, got %d", len(permissions))
}
// Check for specific permissions
hasDBRead := false
hasDBWrite := false
for _, p := range permissions {
if p.Resource == "database_credentials" && p.Action == "read" {
hasDBRead = true
}
if p.Resource == "database_credentials" && p.Action == "write" {
hasDBWrite = true
}
}
if !hasDBRead {
t.Errorf("DB Operator should have database_credentials:read permission")
}
if hasDBWrite {
t.Errorf("DB Operator should not have database_credentials:write permission")
}
})
t.Run("Regular user has no permissions", func(t *testing.T) {
permissions, err := authService.GetUserPermissions("user3")
if err != nil {
t.Errorf("AuthorizationService.GetUserPermissions() error = %v", err)
return
}
// Regular user should have 0 permissions
if len(permissions) != 0 {
t.Errorf("Regular user should have 0 permissions, got %d", len(permissions))
}
})
}

View File

@@ -1,15 +0,0 @@
package data
import "crypto/rand"
const saltLength = 32
func Salt() ([]byte, error) {
salt := make([]byte, saltLength)
_, err := rand.Read(salt)
if err != nil {
return nil, err
}
return salt, nil
}

View File

@@ -1,125 +0,0 @@
package data
import (
"crypto/hmac"
"crypto/rand"
// #nosec G505 - SHA1 is used here because TOTP (RFC 6238) specifically uses HMAC-SHA1
// as the default algorithm, and many authenticator apps still use it.
// In the future, we should consider supporting stronger algorithms like SHA256 or SHA512.
"crypto/sha1"
"encoding/base32"
"encoding/binary"
"fmt"
"image/png"
"os"
"strings"
"time"
"rsc.io/qr"
)
const (
// TOTPTimeStep is the time step in seconds for TOTP.
TOTPTimeStep = 30
// TOTPDigits is the number of digits in a TOTP code.
TOTPDigits = 6
// TOTPModulo is the modulo value for truncating the TOTP hash.
TOTPModulo = 1000000
// TOTPTimeWindow is the number of time steps to check before and after the current time.
TOTPTimeWindow = 1
// Constants for TOTP calculation
timeBytesLength = 8
dynamicTruncationMask = 0x0F
truncationModulusMask = 0x7FFFFFFF
)
// GenerateRandomBase32 generates a random base32 encoded string of the specified length.
func GenerateRandomBase32(length int) (string, error) {
// Generate random bytes
randomBytes := make([]byte, length)
_, err := rand.Read(randomBytes)
if err != nil {
return "", err
}
// Encode to base32
encoder := base32.StdEncoding.WithPadding(base32.NoPadding)
encoded := encoder.EncodeToString(randomBytes)
// Convert to uppercase and remove any padding
return strings.ToUpper(encoded), nil
}
// ValidateTOTP validates a TOTP code against a secret
func ValidateTOTP(secret, code string) bool {
// Get current time step
currentTime := time.Now().Unix() / TOTPTimeStep
// Try the time window (allow for time skew)
for i := -TOTPTimeWindow; i <= TOTPTimeWindow; i++ {
if calculateTOTP(secret, currentTime+int64(i)) == code {
return true
}
}
return false
}
// calculateTOTP calculates the TOTP code for a given secret and time
func calculateTOTP(secret string, timeCounter int64) string {
// Decode the secret from base32
encoder := base32.StdEncoding.WithPadding(base32.NoPadding)
secretBytes, err := encoder.DecodeString(strings.ToUpper(secret))
if err != nil {
return ""
}
// Convert time counter to bytes (big endian)
timeBytes := make([]byte, timeBytesLength)
binary.BigEndian.PutUint64(timeBytes, uint64(timeCounter))
// Calculate HMAC-SHA1
h := hmac.New(sha1.New, secretBytes)
h.Write(timeBytes)
hash := h.Sum(nil)
// Dynamic truncation
offset := hash[len(hash)-1] & dynamicTruncationMask
truncatedHash := binary.BigEndian.Uint32(hash[offset:offset+4]) & truncationModulusMask
otp := truncatedHash % TOTPModulo
// Format as a 6-digit string with leading zeros
return fmt.Sprintf("%0*d", TOTPDigits, otp)
}
// GenerateTOTPQRCode generates a QR code for a TOTP secret and saves it to a file
func GenerateTOTPQRCode(secret, username, issuer, outputPath string) error {
// Format the TOTP URI according to the KeyURI format
// https://github.com/google/google-authenticator/wiki/Key-Uri-Format
uri := fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=%d&period=%d",
issuer, username, secret, issuer, TOTPDigits, TOTPTimeStep)
// Generate QR code
code, err := qr.Encode(uri, qr.M)
if err != nil {
return fmt.Errorf("failed to generate QR code: %w", err)
}
// Create output file
file, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer file.Close()
// Write QR code as PNG
img := code.Image()
err = png.Encode(file, img)
if err != nil {
return fmt.Errorf("failed to write QR code to file: %w", err)
}
return nil
}

View File

@@ -1,14 +0,0 @@
package data
import (
"fmt"
"testing"
"github.com/gokyle/twofactor"
)
func TestTOTPBasic(t *testing.T) {
// This test verifies that we can import and use the twofactor package.
totp := twofactor.TOTP{}
fmt.Printf("TOTP: %+v\n", totp)
}

View File

@@ -1,143 +0,0 @@
package data
import (
"crypto/subtle"
"errors"
"fmt"
"time"
"github.com/oklog/ulid/v2"
"golang.org/x/crypto/scrypt"
)
const (
scryptN = 32768
scryptR = 8
scryptP = 2
// Constants for derived key length and comparison
derivedKeyLength = 32
validCompareResult = 1
// Empty string constant
emptyString = ""
// TOTP secret length in bytes (160 bits)
totpSecretLength = 20
)
type User struct {
ID string
Created int64
User string
Password []byte
Salt []byte
TOTPSecret string
Roles []string
}
// HasRole checks if the user has a specific role
func (u *User) HasRole(role string) bool {
for _, r := range u.Roles {
if r == role {
return true
}
}
return false
}
// HasPermission checks if the user has a specific permission using the authorization service
func (u *User) HasPermission(authService *AuthorizationService, resource, action string) (bool, error) {
return authService.UserHasPermission(u.ID, resource, action)
}
// GetPermissions returns all permissions for the user using the authorization service
func (u *User) GetPermissions(authService *AuthorizationService) ([]Permission, error) {
return authService.GetUserPermissions(u.ID)
}
type Login struct {
User string `json:"user"`
Password string `json:"password,omitempty"`
Token string `json:"token,omitempty"`
TOTPCode string `json:"totp_code,omitempty"`
}
func derive(password string, salt []byte) ([]byte, error) {
return scrypt.Key([]byte(password), salt, scryptN, scryptR, scryptP, derivedKeyLength)
}
// CheckPassword verifies only the username and password, without TOTP verification
func (u *User) CheckPassword(login *Login) bool {
if u.User != login.User {
return false
}
derived, err := derive(login.Password, u.Salt)
if err != nil {
return false
}
return subtle.ConstantTimeCompare(derived, u.Password) == validCompareResult
}
// Check is a legacy method that now only checks the password
// It's kept for backward compatibility but is equivalent to CheckPassword
func (u *User) Check(login *Login) bool {
// Only check username and password, TOTP verification is now a separate flow
return u.CheckPassword(login)
}
func (u *User) Register(login *Login) error {
var err error
if u.User != emptyString && u.User != login.User {
return errors.New("invalid user")
}
if u.ID == emptyString {
u.ID = ulid.Make().String()
}
u.User = login.User
u.Salt, err = Salt()
if err != nil {
return fmt.Errorf("failed to register user: %w", err)
}
u.Password, err = derive(login.Password, u.Salt)
if err != nil {
return fmt.Errorf("key derivation failed: %w", err)
}
u.Created = time.Now().Unix()
return nil
}
// GenerateTOTPSecret generates a new TOTP secret for the user
func (u *User) GenerateTOTPSecret() (string, error) {
// Generate a random secret
secret, err := GenerateRandomBase32(totpSecretLength)
if err != nil {
return emptyString, fmt.Errorf("failed to generate TOTP secret: %w", err)
}
u.TOTPSecret = secret
return u.TOTPSecret, nil
}
// ValidateTOTPCode validates a TOTP code against the user's TOTP secret
func (u *User) ValidateTOTPCode(code string) (bool, error) {
if u.TOTPSecret == emptyString {
return false, errors.New("TOTP not enabled for user")
}
// Use the twofactor package to validate the code
valid := ValidateTOTP(u.TOTPSecret, code)
return valid, nil
}
// HasTOTP returns true if TOTP is enabled for the user
func (u *User) HasTOTP() bool {
return u.TOTPSecret != emptyString
}

View File

@@ -1,83 +0,0 @@
package data
import (
"testing"
)
func TestUserRegisterAndCheck(t *testing.T) {
user := &User{}
login := &Login{
User: "testuser",
Password: "testpassword",
}
err := user.Register(login)
if err != nil {
t.Fatalf("Failed to register user: %v", err)
}
if user.ID == "" {
t.Error("Expected user ID to be set, got empty string")
}
if user.User != "testuser" {
t.Errorf("Expected username 'testuser', got '%s'", user.User)
}
if len(user.Password) == 0 {
t.Error("Expected password to be hashed and set, got empty slice")
}
if len(user.Salt) == 0 {
t.Error("Expected salt to be set, got empty slice")
}
correctLogin := &Login{
User: "testuser",
Password: "testpassword",
}
if !user.Check(correctLogin) {
t.Error("Check failed for correct password")
}
incorrectLogin := &Login{
User: "testuser",
Password: "wrongpassword",
}
if user.Check(incorrectLogin) {
t.Error("Check passed for incorrect password")
}
wrongUserLogin := &Login{
User: "wronguser",
Password: "testpassword",
}
if user.Check(wrongUserLogin) {
t.Error("Check passed for incorrect username")
}
}
func TestSalt(t *testing.T) {
salt, err := Salt()
if err != nil {
t.Fatalf("Failed to generate salt: %v", err)
}
if len(salt) != saltLength {
t.Errorf("Expected salt length %d, got %d", saltLength, len(salt))
}
salt2, err := Salt()
if err != nil {
t.Fatalf("Failed to generate second salt: %v", err)
}
// Check that salts are different (extremely unlikely to be the same)
different := false
for i := 0; i < len(salt); i++ {
if salt[i] != salt2[i] {
different = true
break
}
}
if !different {
t.Error("Expected different salts, got identical values")
}
}

View File

@@ -1,9 +0,0 @@
-- Drop tables in reverse order of creation to avoid foreign key constraints
DROP TABLE IF EXISTS role_permissions;
DROP TABLE IF EXISTS permissions;
DROP TABLE IF EXISTS user_roles;
DROP TABLE IF EXISTS roles;
DROP TABLE IF EXISTS registrations;
DROP TABLE IF EXISTS database;
DROP TABLE IF EXISTS tokens;
DROP TABLE IF EXISTS users;

View File

@@ -1,84 +0,0 @@
CREATE TABLE users (
id text primary key,
created integer,
user text not null,
password blob not null,
salt blob not null,
totp_secret text
);
CREATE TABLE tokens (
id text primary key,
uid text not null,
token text not null,
expires integer default 0,
FOREIGN KEY(uid) REFERENCES user(id)
);
CREATE TABLE database (
id text primary key,
host text not null,
port integer default 5432,
name text not null,
user text not null,
password text not null
);
CREATE TABLE registrations (
id text primary key,
code text not null
);
CREATE TABLE roles (
id text primary key,
role text not null
);
CREATE TABLE user_roles (
id text primary key,
uid text not null,
rid text not null,
FOREIGN KEY(uid) REFERENCES user(id),
FOREIGN KEY(rid) REFERENCES roles(id)
);
-- Add permissions table
CREATE TABLE permissions (
id TEXT PRIMARY KEY,
resource TEXT NOT NULL,
action TEXT NOT NULL,
description TEXT
);
-- Link roles to permissions
CREATE TABLE role_permissions (
id TEXT PRIMARY KEY,
rid TEXT NOT NULL,
pid TEXT NOT NULL,
FOREIGN KEY(rid) REFERENCES roles(id),
FOREIGN KEY(pid) REFERENCES permissions(id)
);
-- Add default permissions
INSERT INTO permissions (id, resource, action, description) VALUES
('perm_db_read', 'database_credentials', 'read', 'Read database credentials'),
('perm_db_write', 'database_credentials', 'write', 'Modify database credentials'),
('perm_user_manage', 'users', 'manage', 'Manage user accounts'),
('perm_token_manage', 'tokens', 'manage', 'Manage authentication tokens');
-- Add default roles
INSERT INTO roles (id, role) VALUES
('role_admin', 'admin'),
('role_db_operator', 'db_operator'),
('role_user', 'user');
-- Grant permissions to admin role
INSERT INTO role_permissions (id, rid, pid) VALUES
('rp_admin_db_read', 'role_admin', 'perm_db_read'),
('rp_admin_db_write', 'role_admin', 'perm_db_write'),
('rp_admin_user_manage', 'role_admin', 'perm_user_manage'),
('rp_admin_token_manage', 'role_admin', 'perm_token_manage');
-- Grant database access to db_operator role
INSERT INTO role_permissions (id, rid, pid) VALUES
('rp_dbop_db_read', 'role_db_operator', 'perm_db_read');

View File

@@ -1,4 +0,0 @@
-- Drop the database_users table and its indexes
DROP INDEX IF EXISTS idx_database_users_db_id;
DROP INDEX IF EXISTS idx_database_users_uid;
DROP TABLE IF EXISTS database_users;

View File

@@ -1,12 +0,0 @@
-- Add database_users table to associate users with databases
CREATE TABLE database_users (
id TEXT PRIMARY KEY,
uid TEXT NOT NULL,
db_id TEXT NOT NULL,
FOREIGN KEY(uid) REFERENCES users(id),
FOREIGN KEY(db_id) REFERENCES database(id)
);
-- Add index for faster lookups
CREATE INDEX idx_database_users_uid ON database_users(uid);
CREATE INDEX idx_database_users_db_id ON database_users(db_id);

View File

@@ -1,18 +0,0 @@
// Package database provides database-related functionality for the MCIAS system.
package database
import (
"embed"
)
//go:embed schema.sql
var schemaFS embed.FS
// DefaultSchema returns the default database schema as a string.
func DefaultSchema() (string, error) {
schemaBytes, err := schemaFS.ReadFile("schema.sql")
if err != nil {
return "", err
}
return string(schemaBytes), nil
}

View File

@@ -1,84 +0,0 @@
CREATE TABLE users (
id text primary key,
created integer,
user text not null,
password blob not null,
salt blob not null,
totp_secret text
);
CREATE TABLE tokens (
id text primary key,
uid text not null,
token text not null,
expires integer default 0,
FOREIGN KEY(uid) REFERENCES user(id)
);
CREATE TABLE database (
id text primary key,
host text not null,
port integer default 5432,
name text not null,
user text not null,
password text not null
);
CREATE TABLE registrations (
id text primary key,
code text not null
);
CREATE TABLE roles (
id text primary key,
role text not null
);
CREATE TABLE user_roles (
id text primary key,
uid text not null,
rid text not null,
FOREIGN KEY(uid) REFERENCES user(id),
FOREIGN KEY(rid) REFERENCES roles(id)
);
-- Add permissions table
CREATE TABLE permissions (
id TEXT PRIMARY KEY,
resource TEXT NOT NULL,
action TEXT NOT NULL,
description TEXT
);
-- Link roles to permissions
CREATE TABLE role_permissions (
id TEXT PRIMARY KEY,
rid TEXT NOT NULL,
pid TEXT NOT NULL,
FOREIGN KEY(rid) REFERENCES roles(id),
FOREIGN KEY(pid) REFERENCES permissions(id)
);
-- Add default permissions
INSERT INTO permissions (id, resource, action, description) VALUES
('perm_db_read', 'database_credentials', 'read', 'Read database credentials'),
('perm_db_write', 'database_credentials', 'write', 'Modify database credentials'),
('perm_user_manage', 'users', 'manage', 'Manage user accounts'),
('perm_token_manage', 'tokens', 'manage', 'Manage authentication tokens');
-- Add default roles
INSERT INTO roles (id, role) VALUES
('role_admin', 'admin'),
('role_db_operator', 'db_operator'),
('role_user', 'user');
-- Grant permissions to admin role
INSERT INTO role_permissions (id, rid, pid) VALUES
('rp_admin_db_read', 'role_admin', 'perm_db_read'),
('rp_admin_db_write', 'role_admin', 'perm_db_write'),
('rp_admin_user_manage', 'role_admin', 'perm_user_manage'),
('rp_admin_token_manage', 'role_admin', 'perm_token_manage');
-- Grant database access to db_operator role
INSERT INTO role_permissions (id, rid, pid) VALUES
('rp_dbop_db_read', 'role_db_operator', 'perm_db_read');

View File

@@ -1,43 +0,0 @@
# MCIAS Documentation
Welcome to the MCIAS (Metacircular Identity and Access System) documentation. This directory contains comprehensive documentation for understanding, installing, and using the MCIAS system.
## Documentation Index
### [Overview](overview.md)
- Project overview
- System architecture
- Database schema
- Security considerations
### [API Documentation](api.md)
- API endpoints
- Request/response formats
- Error handling
- Authentication flow
### [Installation and Usage Guide](installation.md)
- Prerequisites
- Installation steps
- Running the server
- Configuration options
- Building from source
- Development practices
- API usage examples
- Troubleshooting
- Security best practices
## Additional Resources
- [Project Repository](https://git.wntrmute.dev/kyle/mcias)
- [Schema File](../schema.sql) - Database schema definition
## Contributing to Documentation
When contributing to this documentation, please follow these guidelines:
1. Use Markdown format for all documentation files
2. Keep the documentation up-to-date with code changes
3. Include examples where appropriate
4. Organize content logically with clear headings
5. Use code blocks with syntax highlighting for code examples

View File

@@ -1,46 +0,0 @@
#+title: MCIAS Documentation
#+created: <2025-05-09 Fri 13:42>
* MCIAS Documentation
Welcome to the MCIAS (Metacircular Identity and Access System) documentation. This directory contains comprehensive documentation for understanding, installing, and using the MCIAS system.
* Documentation Index
** [[file:overview.org][Overview]]
- Project overview
- System architecture
- Database schema
- Security considerations
** [[file:api.org][API Documentation]]
- API endpoints
- Request/response formats
- Error handling
- Authentication flow
** [[file:installation.org][Installation and Usage Guide]]
- Prerequisites
- Installation steps
- Running the server
- Configuration options
- Building from source
- Development practices
- API usage examples
- Troubleshooting
- Security best practices
* Additional Resources
- [[https://git.wntrmute.dev/kyle/mcias][Project Repository]]
- [[file:../schema.sql][Schema File]] - Database schema definition
* Contributing to Documentation
When contributing to this documentation, please follow these guidelines:
1. Use org-mode format for all documentation files
2. Keep the documentation up-to-date with code changes
3. Include examples where appropriate
4. Organize content logically with clear headings
5. Use code blocks with syntax highlighting for code examples

View File

@@ -1,184 +0,0 @@
# MCIAS API Documentation
## Overview
MCIAS (Metacircular Identity and Access System) provides identity and authentication services across metacircular projects. This document describes the REST API endpoints, request/response formats, and error handling.
## API Endpoints
### Authentication
#### Password-based Authentication
**Endpoint**: `POST /v1/login/password`
**Description**: Authenticates a user using username and password credentials. This endpoint does not require TOTP verification, even if TOTP is enabled for the user.
**Request Format**:
```json
{
"version": "v1",
"login": {
"user": "username",
"password": "secret_password"
}
}
```
**Required Fields**:
- `version`: Must be "v1"
- `login.user`: Username
- `login.password`: User's password
**Response Format** (Success - 200 OK):
```json
{
"token": "authentication_token",
"expires": 1621234567,
"totp_enabled": true
}
```
**Response Fields**:
- `token`: Authentication token to be used for subsequent requests
- `expires`: Unix timestamp when the token expires
- `totp_enabled`: Boolean indicating whether TOTP is enabled for the user
**Error Responses**:
- 400 Bad Request: Invalid request format or missing required fields
- 401 Unauthorized: Invalid username or password
- 500 Internal Server Error: Server-side error
#### Token-based Authentication
**Endpoint**: `POST /v1/login/token`
**Description**: Authenticates a user using a previously issued token.
**Request Format**:
```json
{
"version": "v1",
"login": {
"user": "username",
"token": "existing_token"
}
}
```
**Required Fields**:
- `version`: Must be "v1"
- `login.user`: Username
- `login.token`: Previously issued authentication token
**Response Format** (Success - 200 OK):
```json
{
"token": "new_authentication_token",
"expires": 1621234567
}
```
**Response Fields**:
- `token`: New authentication token to be used for subsequent requests
- `expires`: Unix timestamp when the token expires
**Error Responses**:
- 400 Bad Request: Invalid request format or missing required fields
- 401 Unauthorized: Invalid or expired token
- 500 Internal Server Error: Server-side error
#### TOTP Verification
**Endpoint**: `POST /v1/login/totp`
**Description**: Verifies a TOTP code for a user and issues a token upon successful verification. This endpoint is used as a separate flow from password authentication.
**Request Format**:
```json
{
"version": "v1",
"username": "username",
"totp_code": "123456"
}
```
**Required Fields**:
- `version`: Must be "v1"
- `username`: Username
- `totp_code`: Time-based One-Time Password code
**Response Format** (Success - 200 OK):
```json
{
"token": "authentication_token",
"expires": 1621234567
}
```
**Response Fields**:
- `token`: Authentication token to be used for subsequent requests
- `expires`: Unix timestamp when the token expires
**Error Responses**:
- 400 Bad Request: Invalid request format, missing required fields, or TOTP not enabled for user
- 401 Unauthorized: Invalid TOTP code
- 500 Internal Server Error: Server-side error
### Database Credentials
**Endpoint**: `/v1/credentials/database` (Not yet implemented)
**Description**: Retrieves database credentials for authorized users.
## Error Handling
All error responses follow a standard format:
```json
{
"error": "Error message describing the issue"
}
```
Common HTTP status codes:
- 200 OK: Request successful
- 400 Bad Request: Invalid request format or parameters
- 401 Unauthorized: Authentication failed
- 403 Forbidden: Insufficient permissions
- 404 Not Found: Resource not found
- 500 Internal Server Error: Server-side error
## Authentication Flow
### Password Authentication Flow
1. **Initial Authentication**:
- Client sends username and password to `/v1/login/password`
- Server validates credentials and returns a token
- The response includes a `totp_enabled` flag indicating whether TOTP is enabled for the user
2. **Subsequent Requests**:
- Client uses the token for authentication by sending it to `/v1/login/token`
- Server validates the token and issues a new token
### TOTP Authentication Flow
1. **TOTP Verification** (separate from password authentication):
- Client sends username and TOTP code to `/v1/login/totp`
- Server validates the TOTP code and returns a token if valid
### Token Management
1. **Token Expiration**:
- Tokens expire after 24 hours
- Clients should request a new token before expiration
### Multi-Factor Authentication
For users with TOTP enabled, a complete multi-factor authentication flow would involve:
1. Authenticate with username and password using `/v1/login/password`
2. Check the `totp_enabled` flag in the response
3. If TOTP is enabled, prompt the user for their TOTP code
4. Verify the TOTP code using `/v1/login/totp` to get a second token
5. Use either token for subsequent requests (both are valid)

View File

@@ -1,201 +0,0 @@
#+title: MCIAS API Documentation
#+created: <2025-05-09 Fri 13:42>
* MCIAS API Documentation
** Overview
MCIAS (Metacircular Identity and Access System) provides identity and authentication services across metacircular projects. This document describes the REST API endpoints, request/response formats, and error handling.
** API Endpoints
*** Authentication
**** Password-based Authentication
*Endpoint*: =POST /v1/login/password=
*Description*: Authenticates a user using username and password credentials.
*Request Format*:
#+begin_src json
{
"version": "v1",
"login": {
"user": "username",
"password": "secret_password"
}
}
#+end_src
*Required Fields*:
- =version=: Must be "v1"
- =login.user=: Username
- =login.password=: User's password
*Response Format* (Success - 200 OK):
#+begin_src json
{
"token": "authentication_token",
"expires": 1621234567
}
#+end_src
*Response Fields*:
- =token=: Authentication token to be used for subsequent requests
- =expires=: Unix timestamp when the token expires
*Error Responses*:
- 400 Bad Request: Invalid request format or missing required fields
- 401 Unauthorized: Invalid username or password
- 500 Internal Server Error: Server-side error
**** Token-based Authentication
*Endpoint*: =POST /v1/login/token=
*Description*: Authenticates a user using a previously issued token.
*Request Format*:
#+begin_src json
{
"version": "v1",
"login": {
"user": "username",
"token": "existing_token"
}
}
#+end_src
*Required Fields*:
- =version=: Must be "v1"
- =login.user=: Username
- =login.token=: Previously issued authentication token
*Response Format* (Success - 200 OK):
#+begin_src json
{
"token": "new_authentication_token",
"expires": 1621234567
}
#+end_src
*Response Fields*:
- =token=: New authentication token to be used for subsequent requests
- =expires=: Unix timestamp when the token expires
*Error Responses*:
- 400 Bad Request: Invalid request format or missing required fields
- 401 Unauthorized: Invalid or expired token
- 500 Internal Server Error: Server-side error
*** Database Credentials
*Endpoint*: =GET /v1/database/credentials=
*Description*: Retrieves database credentials for authorized users.
*Request Parameters*:
- =username=: Username of the authenticated user
*Headers*:
- =Authorization=: Bearer token for authentication
*Response Format* (Success - 200 OK):
#+begin_src json
{
"host": "database_host",
"port": 5432,
"name": "database_name",
"user": "database_user",
"password": "database_password"
}
#+end_src
*Error Responses*:
- 400 Bad Request: Invalid request format or missing required parameters
- 401 Unauthorized: Invalid or expired token
- 403 Forbidden: Insufficient permissions to access database credentials
- 500 Internal Server Error: Server-side error
*** TOTP Authentication
*Endpoint*: =POST /v1/login/totp=
*Description*: Authenticates a user using TOTP (Time-based One-Time Password) in addition to username and password.
*Request Format*:
#+begin_src json
{
"version": "v1",
"login": {
"user": "username",
"password": "secret_password",
"totp": "123456"
}
}
#+end_src
*Required Fields*:
- =version=: Must be "v1"
- =login.user=: Username
- =login.password=: User's password
- =login.totp=: 6-digit TOTP code from authenticator app
*Response Format* (Success - 200 OK):
#+begin_src json
{
"token": "authentication_token",
"expires": 1621234567
}
#+end_src
*Response Fields*:
- =token=: Authentication token to be used for subsequent requests
- =expires=: Unix timestamp when the token expires
*Error Responses*:
- 400 Bad Request: Invalid request format or missing required fields
- 401 Unauthorized: Invalid username, password, or TOTP code
- 500 Internal Server Error: Server-side error
** Error Handling
All error responses follow a standard format:
#+begin_src json
{
"error": "Error message describing the issue"
}
#+end_src
Common HTTP status codes:
- 200 OK: Request successful
- 400 Bad Request: Invalid request format or parameters
- 401 Unauthorized: Authentication failed
- 403 Forbidden: Insufficient permissions
- 404 Not Found: Resource not found
- 500 Internal Server Error: Server-side error
** Authentication Flow
1. *Initial Authentication*:
- Client sends username and password to =/v1/login/password=
- Server validates credentials and returns a token
- If TOTP is enabled for the user, authentication with password alone will fail
2. *TOTP Authentication*:
- For users with TOTP enabled, client sends username, password, and TOTP code to =/v1/login/totp=
- Server validates all credentials and returns a token
3. *Subsequent Requests*:
- Client uses the token for authentication by sending it to =/v1/login/token=
- Server validates the token and issues a new token
4. *Token Expiration*:
- Tokens expire after 24 hours
- Clients should request a new token before expiration
5. *Database Credential Access*:
- Client sends a GET request to =/v1/database/credentials= with the token in the Authorization header
- Server validates the token and returns the database credentials if the user has sufficient permissions

View File

@@ -1,130 +0,0 @@
# MCIAS Deployment Guide
## Overview
This document provides guidance on deploying the Metacircular Identity and Access System (MCIAS) in a production environment. MCIAS is designed to be deployed behind a reverse proxy that handles TLS termination and security headers.
## Prerequisites
- Linux server with systemd
- Nginx or another reverse proxy for TLS termination
- User and group `mcias` created on the system
- Go 1.23 or later for building from source
## Installation
1. Build the MCIAS binary:
```bash
go build -o mcias ./cmd/mcias
```
2. Create the installation directory:
```bash
sudo mkdir -p /opt/mcias
```
3. Copy the binary and service file:
```bash
sudo cp mcias /opt/mcias/
sudo cp mcias.service /etc/systemd/system/
```
4. Set appropriate permissions:
```bash
sudo chown -R mcias:mcias /opt/mcias
sudo chmod 755 /opt/mcias/mcias
```
5. Initialize the database:
```bash
sudo -u mcias /opt/mcias/mcias init --db /opt/mcias/mcias.db
```
6. Enable and start the service:
```bash
sudo systemctl daemon-reload
sudo systemctl enable mcias
sudo systemctl start mcias
```
## Reverse Proxy Configuration
MCIAS is designed to run behind a reverse proxy that handles TLS termination and security headers. Below is an example Nginx configuration:
```nginx
server {
listen 443 ssl http2;
server_name mcias.example.com;
# SSL configuration
ssl_certificate /etc/letsencrypt/live/mcias.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mcias.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305';
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_stapling on;
ssl_stapling_verify on;
# Security headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Content-Security-Policy "default-src 'self';" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Proxy to MCIAS
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name mcias.example.com;
return 301 https://$host$request_uri;
}
```
## Security Considerations
1. **TLS Configuration**: The reverse proxy should use modern TLS protocols (TLSv1.2 and TLSv1.3) and strong cipher suites.
2. **Security Headers**: The reverse proxy should add security headers to all responses, as shown in the Nginx configuration example.
3. **Firewall Configuration**: Configure your firewall to only allow connections to the MCIAS server from the reverse proxy.
4. **Regular Updates**: Keep the MCIAS software, operating system, and reverse proxy up to date with security patches.
5. **Monitoring**: Set up monitoring for the MCIAS service and review logs regularly for security events.
## Logging
MCIAS logs to the systemd journal by default. You can view logs using:
```bash
sudo journalctl -u mcias
```
Security events are logged with the prefix `SECURITY_EVENT:` and are in JSON format for easy parsing.
## Backup and Recovery
Regularly back up the MCIAS database file (/opt/mcias/mcias.db) to ensure you can recover in case of data loss.
Example backup script:
```bash
#!/bin/bash
BACKUP_DIR="/var/backups/mcias"
TIMESTAMP=$(date +%Y%m%d%H%M%S)
mkdir -p $BACKUP_DIR
sqlite3 /opt/mcias/mcias.db ".backup $BACKUP_DIR/mcias_$TIMESTAMP.db"
```

View File

@@ -1,170 +0,0 @@
# MCIAS Installation and Usage Guide
## Prerequisites
Before installing MCIAS, ensure you have the following prerequisites:
- Go 1.23 or later
- SQLite3
- golangci-lint (for development)
## Installation
### Clone the Repository
```bash
git clone git.wntrmute.dev/kyle/mcias
cd mcias
```
### Install Dependencies
```bash
go mod download
```
### Initialize the Database
MCIAS uses SQLite for data storage. To initialize the database:
```bash
go run main.go -init -db ./mcias.db
```
This command creates a new SQLite database file and initializes it with the schema defined in `schema.sql`.
## Running the Server
### Basic Usage
To start the MCIAS server with default settings:
```bash
go run main.go -db ./mcias.db
```
By default, the server listens on port 8080.
### Configuration Options
MCIAS supports the following command-line options:
- `-db <path>`: Path to the SQLite database file (default: `mcias.db`)
- `-addr <address>`: Address to listen on (default: `:8080`)
- `-init`: Initialize the database and exit
Example with custom port:
```bash
go run main.go -db ./mcias.db -addr :9000
```
## Building from Source
To build a binary:
```bash
go build -o mcias
```
Then run the binary:
```bash
./mcias -db ./mcias.db
```
## Development
### Running Tests
To run all tests:
```bash
go test ./...
```
### Linting
To run the linter:
```bash
golangci-lint run
```
## Using the API
### Authentication with Password
To authenticate a user with a password:
```bash
curl -X POST http://localhost:8080/v1/login/password \
-H "Content-Type: application/json" \
-d '{
"version": "v1",
"login": {
"user": "username",
"password": "password"
}
}'
```
### Authentication with Token
To authenticate a user with a token:
```bash
curl -X POST http://localhost:8080/v1/login/token \
-H "Content-Type: application/json" \
-d '{
"version": "v1",
"login": {
"user": "username",
"token": "your_token"
}
}'
```
## Troubleshooting
### Common Issues
1. **Database errors**: Ensure the database file exists and has the correct permissions.
```bash
# Check permissions
ls -l mcias.db
# Fix permissions if needed
chmod 644 mcias.db
```
2. **Port already in use**: If the port is already in use, specify a different port with the `-addr` flag.
```bash
go run main.go -db ./mcias.db -addr :8081
```
3. **Authentication failures**: Ensure you're using the correct username and password/token.
### Logging
MCIAS logs to stdout by default. To capture logs to a file:
```bash
go run main.go -db ./mcias.db > mcias.log 2>&1
```
## Security Best Practices
1. **Production Deployment**:
- Use HTTPS in production
- Set up proper firewall rules
- Run the service with minimal privileges
2. **Database Security**:
- Regularly backup the database
- Restrict file permissions on the database file
- Consider encrypting the database file at rest
3. **User Management**:
- Implement strong password policies
- Regularly rotate tokens
- Monitor for suspicious authentication attempts

View File

@@ -1,238 +0,0 @@
#+title: MCIAS Installation and Usage Guide
#+created: <2025-05-09 Fri 13:42>
* MCIAS Installation and Usage Guide
** Prerequisites
Before installing MCIAS, ensure you have the following prerequisites:
- Go 1.23 or later
- SQLite3
- golangci-lint (for development)
** Installation
*** Clone the Repository
#+begin_src bash
git clone git.wntrmute.dev/kyle/mcias
cd mcias
#+end_src
*** Install Dependencies
#+begin_src bash
go mod download
#+end_src
*** Initialize the Database
MCIAS uses SQLite for data storage. To initialize the database:
#+begin_src bash
go run cmd/mcias/main.go init --db ./mcias.db
#+end_src
This command creates a new SQLite database file and initializes it with the schema defined in =schema.sql=.
** Running the Server
*** Basic Usage
To start the MCIAS server with default settings:
#+begin_src bash
go run cmd/mcias/main.go server --db ./mcias.db
#+end_src
By default, the server listens on port 8080.
*** Configuration Options
MCIAS supports the following command-line options for the server:
- =--db <path>=: Path to the SQLite database file (default: =mcias.db=)
- =--addr <address>=: Address to listen on (default: =:8080=)
Example with custom port:
#+begin_src bash
go run cmd/mcias/main.go server --db ./mcias.db --addr :9000
#+end_src
** Managing Users and Authentication
*** Adding a New User
To add a new user to the system:
#+begin_src bash
go run cmd/mcias/main.go user add --username <username> --password <password>
#+end_src
*** Managing TOTP Authentication
To enable TOTP for a user:
#+begin_src bash
go run cmd/mcias/main.go totp enable --username <username>
#+end_src
This will generate a TOTP secret for the user and display it. The user should save this secret in their authenticator app.
To add a TOTP token with QR code generation:
#+begin_src bash
go run cmd/mcias/main.go totp add --username <username> --qr-output <path/to/qrcode.png>
#+end_src
To validate a TOTP code:
#+begin_src bash
go run cmd/mcias/main.go totp validate --username <username> --code <totp_code>
#+end_src
** Building from Source
To build the server binary:
#+begin_src bash
cd cmd/mcias
go build -o mcias
#+end_src
Then run the binary:
#+begin_src bash
./mcias server --db ./mcias.db
#+end_src
To build the client binary:
#+begin_src bash
cd cmd/mcias-client
go build -o mcias-client
#+end_src
** Development
*** Running Tests
To run all tests:
#+begin_src bash
go test ./...
#+end_src
*** Linting
To run the linter:
#+begin_src bash
golangci-lint run
#+end_src
** Using the API
*** Authentication with Password
To authenticate a user with a password:
#+begin_src bash
curl -X POST http://localhost:8080/v1/login/password \
-H "Content-Type: application/json" \
-d '{
"version": "v1",
"login": {
"user": "username",
"password": "password"
}
}'
#+end_src
*** Authentication with Token
To authenticate a user with a token:
#+begin_src bash
curl -X POST http://localhost:8080/v1/login/token \
-H "Content-Type: application/json" \
-d '{
"version": "v1",
"login": {
"user": "username",
"token": "your_token"
}
}'
#+end_src
*** Authentication with TOTP
To authenticate a user with a password and TOTP code:
#+begin_src bash
curl -X POST http://localhost:8080/v1/login/totp \
-H "Content-Type: application/json" \
-d '{
"version": "v1",
"login": {
"user": "username",
"password": "password",
"totp": "123456"
}
}'
#+end_src
*** Retrieving Database Credentials
To retrieve database credentials:
#+begin_src bash
curl -X GET "http://localhost:8080/v1/database/credentials?username=username" \
-H "Authorization: Bearer your_token"
#+end_src
** Troubleshooting
*** Common Issues
1. *Database errors*: Ensure the database file exists and has the correct permissions.
#+begin_src bash
# Check permissions
ls -l mcias.db
# Fix permissions if needed
chmod 644 mcias.db
#+end_src
2. *Port already in use*: If the port is already in use, specify a different port with the =-addr= flag.
#+begin_src bash
go run main.go -db ./mcias.db -addr :8081
#+end_src
3. *Authentication failures*: Ensure you're using the correct username and password/token.
*** Logging
MCIAS logs to stdout by default. To capture logs to a file:
#+begin_src bash
go run main.go -db ./mcias.db > mcias.log 2>&1
#+end_src
** Security Best Practices
1. *Production Deployment*:
- Use HTTPS in production
- Set up proper firewall rules
- Run the service with minimal privileges
2. *Database Security*:
- Regularly backup the database
- Restrict file permissions on the database file
- Consider encrypting the database file at rest
3. *User Management*:
- Implement strong password policies
- Regularly rotate tokens
- Monitor for suspicious authentication attempts

View File

@@ -1,130 +0,0 @@
# MCIAS: Metacircular Identity and Access System
## Project Overview
MCIAS (Metacircular Identity and Access System) is a centralized identity and access management system designed to provide authentication and authorization services across metacircular projects. It serves as a single source of truth for user identity and access control.
The system currently provides:
1. User password authentication
2. User token authentication
3. Database credential authentication
Future planned features include:
1. TOTP (Time-based One-Time Password) authentication
2. Policy management for fine-grained access control
## System Architecture
MCIAS is built as a standalone REST API service with the following components:
### Core Components
1. **API Layer** (`api/` directory)
- HTTP server and routing
- Request/response handling
- Authentication endpoints
- Error handling
2. **Data Layer** (`data/` directory)
- User management
- Token management
- Password hashing and verification
- Secure random generation
3. **Database** (SQLite)
- Persistent storage for users, tokens, and credentials
- Schema defined in `schema.sql`
### Request Flow
1. Client sends authentication request to the API
2. API layer validates the request format
3. Data layer processes the authentication logic
4. Database is queried to verify credentials
5. Response is generated and sent back to the client
## Database Schema
MCIAS uses a SQLite database with the following tables:
### Users Table
```sql
CREATE TABLE users (
id text primary key,
created integer,
user text not null,
password blob not null,
salt blob not null
);
```
### Tokens Table
```sql
CREATE TABLE tokens (
id text primary key,
uid text not null,
token text not null,
expires integer default 0,
FOREIGN KEY(uid) REFERENCES user(id)
);
```
### Database Credentials Table
```sql
CREATE TABLE database (
id text primary key,
host text not null,
port integer default 5432,
name text not null,
user text not null,
password text not null
);
```
### Registrations Table
```sql
CREATE TABLE registrations (
id text primary key,
code text not null
);
```
### Roles Tables
```sql
CREATE TABLE roles (
id text primary key,
role text not null
);
CREATE TABLE user_roles (
id text primary key,
uid text not null,
rid text not null,
FOREIGN KEY(uid) REFERENCES user(id),
FOREIGN KEY(rid) REFERENCES roles(id)
);
```
## Security Considerations
MCIAS implements several security best practices:
1. **Password Security**
- Passwords are never stored in plaintext
- Scrypt key derivation function is used for password hashing
- Each user has a unique random salt
- Constant-time comparison is used to prevent timing attacks
2. **Token Security**
- Tokens are generated using cryptographically secure random functions
- Tokens have an expiration time (24 hours by default)
- New tokens are issued on each successful authentication
3. **API Security**
- Input validation on all endpoints
- Standardized error responses that don't leak sensitive information
- Rate limiting (to be implemented)
4. **Database Security**
- Parameterized queries to prevent SQL injection
- Foreign key constraints to maintain data integrity

View File

@@ -1,152 +0,0 @@
#+title: MCIAS: Metacircular Identity and Access System
#+created: <2025-05-09 Fri 13:42>
* MCIAS: Metacircular Identity and Access System
** Project Overview
MCIAS (Metacircular Identity and Access System) is a centralized identity and access management system designed to provide authentication and authorization services across metacircular projects. It serves as a single source of truth for user identity and access control.
The system currently provides:
1. User password authentication
2. User token authentication
3. Database credential authentication
4. TOTP (Time-based One-Time Password) authentication
Future planned features include:
1. Policy management for fine-grained access control
** System Architecture
MCIAS is built as a standalone REST API service with the following components:
*** Core Components
1. *API Layer* (=api/= directory)
- HTTP server and routing
- Request/response handling
- Authentication endpoints
- Error handling
2. *Data Layer* (=data/= directory)
- User management
- Token management
- Password hashing and verification
- Secure random generation
3. *Database* (SQLite)
- Persistent storage for users, tokens, and credentials
- Schema defined in =schema.sql=
*** Request Flow
1. Client sends authentication request to the API
2. API layer validates the request format
3. Data layer processes the authentication logic
4. Database is queried to verify credentials
5. Response is generated and sent back to the client
** Database Schema
MCIAS uses a SQLite database with the following tables:
*** Users Table
#+begin_src sql
CREATE TABLE users (
id text primary key,
created integer,
user text not null,
password blob not null,
salt blob not null,
totp_secret text
);
#+end_src
*** Tokens Table
#+begin_src sql
CREATE TABLE tokens (
id text primary key,
uid text not null,
token text not null,
expires integer default 0,
FOREIGN KEY(uid) REFERENCES user(id)
);
#+end_src
*** Database Credentials Table
#+begin_src sql
CREATE TABLE database (
id text primary key,
host text not null,
port integer default 5432,
name text not null,
user text not null,
password text not null
);
#+end_src
*** Registrations Table
#+begin_src sql
CREATE TABLE registrations (
id text primary key,
code text not null
);
#+end_src
*** Roles Tables
#+begin_src sql
CREATE TABLE roles (
id text primary key,
role text not null
);
CREATE TABLE user_roles (
id text primary key,
uid text not null,
rid text not null,
FOREIGN KEY(uid) REFERENCES user(id),
FOREIGN KEY(rid) REFERENCES roles(id)
);
#+end_src
*** Permissions Tables
#+begin_src sql
CREATE TABLE permissions (
id TEXT PRIMARY KEY,
resource TEXT NOT NULL,
action TEXT NOT NULL,
description TEXT
);
CREATE TABLE role_permissions (
id TEXT PRIMARY KEY,
rid TEXT NOT NULL,
pid TEXT NOT NULL,
FOREIGN KEY(rid) REFERENCES roles(id),
FOREIGN KEY(pid) REFERENCES permissions(id)
);
#+end_src
** Security Considerations
MCIAS implements several security best practices:
1. *Password Security*
- Passwords are never stored in plaintext
- Scrypt key derivation function is used for password hashing
- Each user has a unique random salt
- Constant-time comparison is used to prevent timing attacks
2. *Token Security*
- Tokens are generated using cryptographically secure random functions
- Tokens have an expiration time (24 hours by default)
- New tokens are issued on each successful authentication
3. *API Security*
- Input validation on all endpoints
- Standardized error responses that don't leak sensitive information
- Rate limiting (to be implemented)
4. *Database Security*
- Parameterized queries to prevent SQL injection
- Foreign key constraints to maintain data integrity

1
go.mod
View File

@@ -6,6 +6,7 @@ require github.com/gokyle/twofactor v1.0.1
require ( require (
github.com/Masterminds/squirrel v1.5.4 // indirect github.com/Masterminds/squirrel v1.5.4 // indirect
github.com/cloudflare/golz4 v0.0.0-20240921210912-c4df3fe31cbd // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/golang-migrate/migrate/v4 v4.18.3 // indirect github.com/golang-migrate/migrate/v4 v4.18.3 // indirect

2
go.sum
View File

@@ -1,5 +1,7 @@
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/cloudflare/golz4 v0.0.0-20240921210912-c4df3fe31cbd h1:r6CfhFeB1zJY9UVvmsDyFxwJIKXLcmps03FlEs460zI=
github.com/cloudflare/golz4 v0.0.0-20240921210912-c4df3fe31cbd/go.mod h1:EOBUe0h4xcZ5GoxqC5SDxFQ8gwyZPKQoEzownBlhI80=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=