Files
silo/internal/db/projects.go
Forbes 4f0107f1b2 feat(auth): add authentication, RBAC, API tokens, and default admin
Add a complete authentication and authorization system to Silo with
three pluggable backends (local bcrypt, LDAP/FreeIPA, OIDC/Keycloak),
session management, API token support, and role-based access control.

Authentication backends:
- Local: bcrypt (cost 12) password verification against users table
- LDAP: FreeIPA simple bind with group-to-role mapping
- OIDC: Keycloak redirect flow with realm role mapping
- Backends are tried in order; users upserted to DB on first login

Session and token management:
- PostgreSQL-backed sessions via alexedwards/scs + pgxstore
- Opaque API tokens (silo_ prefix, SHA-256 hashed, shown once)
- 24h session lifetime, HttpOnly/SameSite=Lax/Secure cookies

Role-based access control (admin > editor > viewer):
- RequireAuth middleware: Bearer token -> session -> redirect/401
- RequireRole middleware: per-route-group minimum role enforcement
- CSRF protection via justinas/nosurf on web forms, API exempt
- CORS locked to configured origins when auth enabled

Route restructuring:
- Public: /health, /ready, /login, /auth/oidc, /auth/callback
- Web (auth + CSRF): /, /projects, /schemas, /settings
- API read (viewer): GET /api/**
- API write (editor): POST/PUT/PATCH/DELETE /api/**

User context wiring:
- created_by/updated_by columns on items, projects, relationships
- All create/update handlers populate tracking fields from context
- CSV and BOM import handlers pass authenticated username
- Revision creation tracks user across all code paths

Default admin account:
- Configurable via auth.local.default_admin_username/password
- Env var overrides: SILO_ADMIN_USERNAME, SILO_ADMIN_PASSWORD
- Idempotent: created on first startup, skipped if exists

CLI and FreeCAD plugin:
- silo token create/list/revoke subcommands (HTTP API client)
- FreeCAD SiloClient sends Bearer token on all requests
- Token read from ApiToken preference or SILO_API_TOKEN env var

Web UI:
- Login page (Catppuccin Mocha themed, OIDC button conditional)
- Settings page with account info and API token management
- User display name, role badge, and logout button in header
- One-time token display banner with copy-to-clipboard

Database (migration 009):
- users table with role, auth_source, oidc_subject, password_hash
- api_tokens table with SHA-256 hash, prefix, expiry, scopes
- sessions table (scs pgxstore schema)
- audit_log table (schema ready for future use)
- created_by/updated_by ALTER on items, relationships, projects

New dependencies: scs/v2, scs/pgxstore, go-oidc/v3, go-ldap/v3,
justinas/nosurf, golang.org/x/oauth2
2026-01-31 11:20:12 -06:00

301 lines
7.6 KiB
Go

package db
import (
"context"
"time"
"github.com/jackc/pgx/v5"
)
// Project represents a project in the database.
type Project struct {
ID string
Code string
Name string
Description string
CreatedAt time.Time
CreatedBy *string
}
// ProjectRepository provides project database operations.
type ProjectRepository struct {
db *DB
}
// NewProjectRepository creates a new project repository.
func NewProjectRepository(db *DB) *ProjectRepository {
return &ProjectRepository{db: db}
}
// List returns all projects.
func (r *ProjectRepository) List(ctx context.Context) ([]*Project, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT id, code, name, description, created_at
FROM projects
ORDER BY code
`)
if err != nil {
return nil, err
}
defer rows.Close()
var projects []*Project
for rows.Next() {
p := &Project{}
var name, desc *string
if err := rows.Scan(&p.ID, &p.Code, &name, &desc, &p.CreatedAt); err != nil {
return nil, err
}
if name != nil {
p.Name = *name
}
if desc != nil {
p.Description = *desc
}
projects = append(projects, p)
}
return projects, rows.Err()
}
// GetByCode returns a project by its code.
func (r *ProjectRepository) GetByCode(ctx context.Context, code string) (*Project, error) {
p := &Project{}
var name, desc *string
err := r.db.pool.QueryRow(ctx, `
SELECT id, code, name, description, created_at
FROM projects
WHERE code = $1
`, code).Scan(&p.ID, &p.Code, &name, &desc, &p.CreatedAt)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if name != nil {
p.Name = *name
}
if desc != nil {
p.Description = *desc
}
return p, nil
}
// GetByID returns a project by its ID.
func (r *ProjectRepository) GetByID(ctx context.Context, id string) (*Project, error) {
p := &Project{}
var name, desc *string
err := r.db.pool.QueryRow(ctx, `
SELECT id, code, name, description, created_at
FROM projects
WHERE id = $1
`, id).Scan(&p.ID, &p.Code, &name, &desc, &p.CreatedAt)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if name != nil {
p.Name = *name
}
if desc != nil {
p.Description = *desc
}
return p, nil
}
// Create inserts a new project.
func (r *ProjectRepository) Create(ctx context.Context, p *Project) error {
return r.db.pool.QueryRow(ctx, `
INSERT INTO projects (code, name, description, created_by)
VALUES ($1, $2, $3, $4)
RETURNING id, created_at
`, p.Code, nullIfEmpty(p.Name), nullIfEmpty(p.Description), p.CreatedBy).Scan(&p.ID, &p.CreatedAt)
}
// Update updates a project's name and description.
func (r *ProjectRepository) Update(ctx context.Context, code string, name, description string) error {
_, err := r.db.pool.Exec(ctx, `
UPDATE projects
SET name = $2, description = $3
WHERE code = $1
`, code, nullIfEmpty(name), nullIfEmpty(description))
return err
}
// Delete removes a project (will cascade to item_projects).
func (r *ProjectRepository) Delete(ctx context.Context, code string) error {
_, err := r.db.pool.Exec(ctx, `DELETE FROM projects WHERE code = $1`, code)
return err
}
// AddItemToProject associates an item with a project.
func (r *ProjectRepository) AddItemToProject(ctx context.Context, itemID, projectID string) error {
_, err := r.db.pool.Exec(ctx, `
INSERT INTO item_projects (item_id, project_id)
VALUES ($1, $2)
ON CONFLICT (item_id, project_id) DO NOTHING
`, itemID, projectID)
return err
}
// AddItemToProjectByCode associates an item with a project by code.
func (r *ProjectRepository) AddItemToProjectByCode(ctx context.Context, itemID, projectCode string) error {
_, err := r.db.pool.Exec(ctx, `
INSERT INTO item_projects (item_id, project_id)
SELECT $1, id FROM projects WHERE code = $2
ON CONFLICT (item_id, project_id) DO NOTHING
`, itemID, projectCode)
return err
}
// RemoveItemFromProject removes an item's association with a project.
func (r *ProjectRepository) RemoveItemFromProject(ctx context.Context, itemID, projectID string) error {
_, err := r.db.pool.Exec(ctx, `
DELETE FROM item_projects
WHERE item_id = $1 AND project_id = $2
`, itemID, projectID)
return err
}
// RemoveItemFromProjectByCode removes an item's association with a project by code.
func (r *ProjectRepository) RemoveItemFromProjectByCode(ctx context.Context, itemID, projectCode string) error {
_, err := r.db.pool.Exec(ctx, `
DELETE FROM item_projects
WHERE item_id = $1 AND project_id = (SELECT id FROM projects WHERE code = $2)
`, itemID, projectCode)
return err
}
// GetProjectsForItem returns all projects associated with an item.
func (r *ProjectRepository) GetProjectsForItem(ctx context.Context, itemID string) ([]*Project, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT p.id, p.code, p.name, p.description, p.created_at
FROM projects p
JOIN item_projects ip ON ip.project_id = p.id
WHERE ip.item_id = $1
ORDER BY p.code
`, itemID)
if err != nil {
return nil, err
}
defer rows.Close()
var projects []*Project
for rows.Next() {
p := &Project{}
var name, desc *string
if err := rows.Scan(&p.ID, &p.Code, &name, &desc, &p.CreatedAt); err != nil {
return nil, err
}
if name != nil {
p.Name = *name
}
if desc != nil {
p.Description = *desc
}
projects = append(projects, p)
}
return projects, rows.Err()
}
// GetProjectCodesForItem returns project codes for an item (convenience method).
func (r *ProjectRepository) GetProjectCodesForItem(ctx context.Context, itemID string) ([]string, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT p.code
FROM projects p
JOIN item_projects ip ON ip.project_id = p.id
WHERE ip.item_id = $1
ORDER BY p.code
`, itemID)
if err != nil {
return nil, err
}
defer rows.Close()
var codes []string
for rows.Next() {
var code string
if err := rows.Scan(&code); err != nil {
return nil, err
}
codes = append(codes, code)
}
return codes, rows.Err()
}
// GetItemsForProject returns all items associated with a project.
func (r *ProjectRepository) GetItemsForProject(ctx context.Context, projectID string) ([]*Item, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT i.id, i.part_number, i.schema_id, i.item_type, i.description,
i.created_at, i.updated_at, i.archived_at, i.current_revision,
i.cad_synced_at, i.cad_file_path
FROM items i
JOIN item_projects ip ON ip.item_id = i.id
WHERE ip.project_id = $1 AND i.archived_at IS NULL
ORDER BY i.part_number
`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []*Item
for rows.Next() {
item := &Item{}
if err := rows.Scan(
&item.ID, &item.PartNumber, &item.SchemaID, &item.ItemType, &item.Description,
&item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision,
&item.CADSyncedAt, &item.CADFilePath,
); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
// SetItemProjects replaces all project associations for an item.
func (r *ProjectRepository) SetItemProjects(ctx context.Context, itemID string, projectCodes []string) error {
return r.db.Tx(ctx, func(tx pgx.Tx) error {
// Remove existing associations
_, err := tx.Exec(ctx, `DELETE FROM item_projects WHERE item_id = $1`, itemID)
if err != nil {
return err
}
// Add new associations
for _, code := range projectCodes {
_, err := tx.Exec(ctx, `
INSERT INTO item_projects (item_id, project_id)
SELECT $1, id FROM projects WHERE code = $2
ON CONFLICT (item_id, project_id) DO NOTHING
`, itemID, code)
if err != nil {
return err
}
}
return nil
})
}
// helper function
func nullIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}