go first commit

This commit is contained in:
Leon Bösche
2025-12-17 22:57:57 +01:00
parent e5a4de7aab
commit 7749ebfd08
22 changed files with 1044 additions and 0 deletions

14
go_cloud/.env.example Normal file
View File

@@ -0,0 +1,14 @@
# Server Configuration
SERVER_ADDR=:8080
# Database
DATABASE_URL=postgres://user:password@localhost/dbname?sslmode=disable
# OIDC Configuration
OIDC_ISSUER_URL=https://storage.b0esche.cloud/index.php/apps/oidc_login
OIDC_REDIRECT_URL=http://localhost:8080/auth/callback
OIDC_CLIENT_ID=your_client_id
OIDC_CLIENT_SECRET=your_client_secret
# JWT
JWT_SECRET=your_jwt_secret_key

16
go_cloud/Makefile Normal file
View File

@@ -0,0 +1,16 @@
.PHONY: build run test clean lint
build:
go build -o bin/api ./cmd/api
run:
go run ./cmd/api
test:
go test ./...
clean:
rm -rf bin/
lint:
golangci-lint run

113
go_cloud/README.md Normal file
View File

@@ -0,0 +1,113 @@
# Go Backend Control Plane
This is the Go backend for the enterprise document collaboration platform, serving as the single source of truth for authentication, organizations, permissions, sessions, and orchestration.
## Architecture
The backend is designed for security, scale, auditability, and SaaS readiness. It uses OIDC authentication via Nextcloud and enforces organization-scoped permissions.
### Core Principles
- **Single Source of Truth**: All auth, orgs, permissions, and sessions are managed here.
- **Untrusted Frontend**: The backend does not trust client-side permissions.
- **Organization Scoping**: Every API request is org-scoped.
- **Audit Logging**: All sensitive actions are logged.
### Key Components
- **Authentication**: OIDC via Nextcloud
- **Sessions**: Short-lived JWTs (5-15 minutes)
- **Organizations**: Multi-org support with role-based permissions
- **File Mediation**: Talks to Nextcloud via WebDAV/APIs, no direct client access
- **Document Sessions**: Generates signed URLs for viewers and editors (Collabora)
### Tech Stack
- **Language**: Go 1.22+
- **Framework**: Chi router
- **Database**: PostgreSQL 16
- **JWT**: golang-jwt/jwt/v4
- **Deployment**: Ready for containerization
## Project Structure
```
.
├── cmd/api/main.go # Application entry point
├── internal/
│ ├── auth/ # OIDC authentication logic
│ ├── session/ # Session management
│ ├── org/ # Organization resolution
│ ├── permission/ # Permission checking
│ ├── files/ # File access mediation
│ ├── documents/ # Document session generation
│ ├── collabora/ # Collabora editor integration
│ ├── audit/ # Audit logging
│ ├── middleware/ # HTTP middleware (auth, org, rate limit)
│ ├── config/ # Configuration loading
│ ├── database/ # DB connections and migrations
│ └── http/ # HTTP server and routes
├── pkg/jwt/ # JWT utilities
├── migrations/ # Database migrations
├── scripts/ # Utility scripts
├── .env.example # Environment variables template
├── Makefile # Build and deployment tasks
└── README.md
```
## Getting Started
1. **Initialize**:
```bash
go mod init go.b0esche.cloud/backend
go mod tidy
```
2. **Set up PostgreSQL**:
- Install PostgreSQL 16
- Create a database
- Run migrations:
```bash
psql -d your_database < migrations/0001_initial.sql
```
3. **Configure Environment**:
Copy `.env.example` to `.env` and fill in values, especially database URL and OIDC settings.
4. **Run**:
```bash
go run cmd/api/main.go
```
5. **Health Check**:
```bash
curl http://localhost:8080/health
```
## API Endpoints
- `GET /health` - Health check
- `GET /auth/login` - Initiate OIDC login (redirects to Nextcloud)
- `GET /auth/callback` - OIDC callback (returns JWT token)
## Development
- Use Go 1.22+
- Follow Go conventions
- Add tests for critical components
- Use structured logging
- No global mutable state
- No business logic in handlers
## Security
- All handlers must go through middleware chain
- JWTs are short-lived
- Permissions resolved server-side only
- Audit all sensitive actions
- Rate limiting implemented
- No direct DB access from handlers
## Roadmap
1. Complete OIDC implementation
2. Add database integration
3. Implement org and permission resolution
4. Add file access APIs
5. Integrate with Nextcloud and Collabora

BIN
go_cloud/api Executable file

Binary file not shown.

BIN
go_cloud/bin/api Executable file

Binary file not shown.

43
go_cloud/cmd/api/main.go Normal file
View File

@@ -0,0 +1,43 @@
package main
import (
"fmt"
"net/http"
"os"
"go.b0esche.cloud/backend/internal/audit"
"go.b0esche.cloud/backend/internal/auth"
"go.b0esche.cloud/backend/internal/config"
"go.b0esche.cloud/backend/internal/database"
httpsrv "go.b0esche.cloud/backend/internal/http"
"go.b0esche.cloud/backend/pkg/jwt"
)
func main() {
cfg := config.Load()
dbConn, err := database.Connect(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "Database connection error: %v\n", err)
os.Exit(1)
}
db := database.New(dbConn)
jwtManager := jwt.NewManager(cfg.JWTSecret)
authService, err := auth.NewService(cfg, db)
if err != nil {
fmt.Fprintf(os.Stderr, "Auth service error: %v\n", err)
os.Exit(1)
}
auditLogger := audit.NewLogger(db)
srv := httpsrv.New(cfg, db, jwtManager, authService, auditLogger)
fmt.Printf("Starting server on %s\n", cfg.ServerAddr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Fprintf(os.Stderr, "Server error: %v\n", err)
os.Exit(1)
}
}

23
go_cloud/go.mod Normal file
View File

@@ -0,0 +1,23 @@
module go.b0esche.cloud/backend
go 1.25.5
require (
github.com/go-chi/chi/v5 v5.2.3
github.com/golang-jwt/jwt/v5 v5.3.0
)
require (
github.com/coreos/go-oidc/v3 v3.17.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.6 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/oauth2 v0.28.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/text v0.24.0 // indirect
)

35
go_cloud/go.sum Normal file
View File

@@ -0,0 +1,35 @@
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,44 @@
package audit
import (
"context"
"encoding/json"
"log"
"go.b0esche.cloud/backend/internal/database"
"github.com/google/uuid"
)
type Logger struct {
db *database.DB
}
func NewLogger(db *database.DB) *Logger {
return &Logger{db: db}
}
type Entry struct {
UserID *uuid.UUID
OrgID *uuid.UUID
Action string
Resource *string
Success bool
Metadata map[string]interface{}
}
func (l *Logger) Log(ctx context.Context, entry Entry) {
metadataJSON, err := json.Marshal(entry.Metadata)
if err != nil {
log.Printf("Failed to marshal audit metadata: %v", err)
metadataJSON = []byte("{}")
}
_, err = l.db.ExecContext(ctx, `
INSERT INTO audit_logs (user_id, org_id, action, resource, success, metadata)
VALUES ($1, $2, $3, $4, $5, $6)
`, entry.UserID, entry.OrgID, entry.Action, entry.Resource, entry.Success, metadataJSON)
if err != nil {
log.Printf("Failed to log audit entry: %v", err)
}
}

View File

@@ -0,0 +1,96 @@
package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"time"
"go.b0esche.cloud/backend/internal/config"
"go.b0esche.cloud/backend/internal/database"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
type Service struct {
provider *oidc.Provider
oauth2Config oauth2.Config
db *database.DB // Assume we have a DB wrapper
}
func NewService(cfg *config.Config, db *database.DB) (*Service, error) {
ctx := context.Background()
provider, err := oidc.NewProvider(ctx, cfg.OIDCIssuerURL)
if err != nil {
return nil, fmt.Errorf("failed to get OIDC provider: %w", err)
}
oauth2Config := oauth2.Config{
ClientID: cfg.OIDCClientID,
ClientSecret: cfg.OIDCClientSecret,
RedirectURL: cfg.OIDCRedirectURL, // Add to config
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
return &Service{
provider: provider,
oauth2Config: oauth2Config,
db: db,
}, nil
}
func (s *Service) LoginURL(state string) string {
return s.oauth2Config.AuthCodeURL(state)
}
func (s *Service) HandleCallback(ctx context.Context, code, state string) (*database.User, *database.Session, error) {
oauth2Token, err := s.oauth2Config.Exchange(ctx, code)
if err != nil {
return nil, nil, fmt.Errorf("failed to exchange code: %w", err)
}
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
if !ok {
return nil, nil, fmt.Errorf("no id_token in token response")
}
idToken, err := s.provider.Verifier(&oidc.Config{ClientID: s.oauth2Config.ClientID}).Verify(ctx, rawIDToken)
if err != nil {
return nil, nil, fmt.Errorf("failed to verify ID token: %w", err)
}
var claims struct {
Sub string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name"`
}
if err := idToken.Claims(&claims); err != nil {
return nil, nil, fmt.Errorf("failed to parse claims: %w", err)
}
user, err := s.db.GetOrCreateUser(ctx, claims.Sub, claims.Email, claims.Name)
if err != nil {
return nil, nil, err
}
session, err := s.db.CreateSession(ctx, user.ID, time.Now().Add(15*time.Minute))
if err != nil {
return nil, nil, err
}
return user, session, nil
}
// GenerateState generates a secure random state string
func GenerateState() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}

View File

@@ -0,0 +1,29 @@
package auth
import (
"testing"
)
func TestGenerateState(t *testing.T) {
state1, err := GenerateState()
if err != nil {
t.Fatal(err)
}
state2, err := GenerateState()
if err != nil {
t.Fatal(err)
}
if state1 == state2 {
t.Error("States should be unique")
}
if len(state1) == 0 {
t.Error("State should not be empty")
}
}
func TestNewService(t *testing.T) {
// Mock db
// service, err := NewService(cfg, db)
// TODO: Mock database for full test
t.Skip("Requires database mock")
}

View File

@@ -0,0 +1,34 @@
package config
import (
"os"
)
type Config struct {
ServerAddr string
DatabaseURL string
OIDCIssuerURL string
OIDCRedirectURL string
OIDCClientID string
OIDCClientSecret string
JWTSecret string
}
func Load() *Config {
return &Config{
ServerAddr: getEnv("SERVER_ADDR", ":8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
OIDCIssuerURL: os.Getenv("OIDC_ISSUER_URL"),
OIDCRedirectURL: os.Getenv("OIDC_REDIRECT_URL"),
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
JWTSecret: os.Getenv("JWT_SECRET"),
}
}
func getEnv(key, defaultVal string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultVal
}

View File

@@ -0,0 +1,29 @@
package database
import (
"context"
"database/sql"
"fmt"
"go.b0esche.cloud/backend/internal/config"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/stdlib"
)
func Connect(cfg *config.Config) (*sql.DB, error) {
ctx := context.Background()
pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
if err != nil {
return nil, fmt.Errorf("failed to create pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
db := stdlib.OpenDBFromPool(pool)
return db, nil
}

View File

@@ -0,0 +1,124 @@
package database
import (
"context"
"database/sql"
"time"
"github.com/google/uuid"
)
type DB struct {
*sql.DB
}
func New(db *sql.DB) *DB {
return &DB{DB: db}
}
type User struct {
ID uuid.UUID
Email string
DisplayName string
CreatedAt time.Time
LastLoginAt *time.Time
}
type Session struct {
ID uuid.UUID
UserID uuid.UUID
ExpiresAt time.Time
RevokedAt *time.Time
}
type Organization struct {
ID uuid.UUID
Name string
Slug string
CreatedAt time.Time
}
type Membership struct {
UserID uuid.UUID
OrgID uuid.UUID
Role string
CreatedAt time.Time
}
func (db *DB) GetOrCreateUser(ctx context.Context, sub, email, name string) (*User, error) {
var user User
err := db.QueryRowContext(ctx, `
INSERT INTO users (id, email, display_name)
VALUES (gen_random_uuid(), $1, $2)
ON CONFLICT (email) DO UPDATE SET
display_name = EXCLUDED.display_name,
last_login_at = NOW()
RETURNING id, email, display_name, created_at, last_login_at
`, email, name).Scan(&user.ID, &user.Email, &user.DisplayName, &user.CreatedAt, &user.LastLoginAt)
if err != nil {
return nil, err
}
return &user, nil
}
func (db *DB) CreateSession(ctx context.Context, userID uuid.UUID, expiresAt time.Time) (*Session, error) {
var session Session
err := db.QueryRowContext(ctx, `
INSERT INTO sessions (user_id, expires_at)
VALUES ($1, $2)
RETURNING id, user_id, expires_at, revoked_at
`, userID, expiresAt).Scan(&session.ID, &session.UserID, &session.ExpiresAt, &session.RevokedAt)
if err != nil {
return nil, err
}
return &session, nil
}
func (db *DB) GetSession(ctx context.Context, sessionID uuid.UUID) (*Session, error) {
var session Session
err := db.QueryRowContext(ctx, `
SELECT id, user_id, expires_at, revoked_at
FROM sessions
WHERE id = $1
`, sessionID).Scan(&session.ID, &session.UserID, &session.ExpiresAt, &session.RevokedAt)
if err != nil {
return nil, err
}
return &session, nil
}
func (db *DB) GetUserOrganizations(ctx context.Context, userID uuid.UUID) ([]Organization, error) {
rows, err := db.QueryContext(ctx, `
SELECT o.id, o.name, o.slug, o.created_at
FROM organizations o
JOIN memberships m ON o.id = m.org_id
WHERE m.user_id = $1
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var orgs []Organization
for rows.Next() {
var org Organization
if err := rows.Scan(&org.ID, &org.Name, &org.Slug, &org.CreatedAt); err != nil {
return nil, err
}
orgs = append(orgs, org)
}
return orgs, rows.Err()
}
func (db *DB) GetUserMembership(ctx context.Context, userID, orgID uuid.UUID) (*Membership, error) {
var membership Membership
err := db.QueryRowContext(ctx, `
SELECT user_id, org_id, role, created_at
FROM memberships
WHERE user_id = $1 AND org_id = $2
`, userID, orgID).Scan(&membership.UserID, &membership.OrgID, &membership.Role, &membership.CreatedAt)
if err != nil {
return nil, err
}
return &membership, nil
}

View File

@@ -0,0 +1,93 @@
package http
import (
"net/http"
"go.b0esche.cloud/backend/internal/audit"
"go.b0esche.cloud/backend/internal/auth"
"go.b0esche.cloud/backend/internal/config"
"go.b0esche.cloud/backend/internal/database"
"go.b0esche.cloud/backend/internal/middleware"
"go.b0esche.cloud/backend/pkg/jwt"
"github.com/go-chi/chi/v5"
)
func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, authService *auth.Service, auditLogger *audit.Logger) http.Handler {
r := chi.NewRouter()
// Global middleware
r.Use(middleware.RequestID)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.RateLimit)
// Health check
r.Get("/health", healthHandler)
// Auth routes (no auth required)
r.Route("/auth", func(r chi.Router) {
r.Get("/login", func(w http.ResponseWriter, req *http.Request) {
authLoginHandler(w, req, authService)
})
r.Get("/callback", func(w http.ResponseWriter, req *http.Request) {
authCallbackHandler(w, req, cfg, authService, jwtManager, auditLogger)
})
})
// Auth middleware for protected routes
r.Use(middleware.Auth(jwtManager, db))
return r
}
func healthHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
func authLoginHandler(w http.ResponseWriter, r *http.Request, authService *auth.Service) {
state, err := auth.GenerateState()
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// TODO: Store state securely (e.g., in session or cache)
url := authService.LoginURL(state)
http.Redirect(w, r, url, http.StatusFound)
}
func authCallbackHandler(w http.ResponseWriter, r *http.Request, cfg *config.Config, authService *auth.Service, jwtManager *jwt.Manager, auditLogger *audit.Logger) {
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
// TODO: Validate state
user, session, err := authService.HandleCallback(r.Context(), code, state)
if err != nil {
auditLogger.Log(r.Context(), audit.Entry{
Action: "login",
Success: false,
Metadata: map[string]interface{}{"error": err.Error()},
})
http.Error(w, "Authentication failed", http.StatusUnauthorized)
return
}
token, err := jwtManager.Generate(user.Email, []string{}, session.ID.String()) // Orgs not yet
if err != nil {
http.Error(w, "Token generation failed", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &user.ID,
Action: "login",
Success: true,
})
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"token": "` + token + `"}`))
}

View File

@@ -0,0 +1,26 @@
package http
import (
"net/http"
"go.b0esche.cloud/backend/internal/audit"
"go.b0esche.cloud/backend/internal/auth"
"go.b0esche.cloud/backend/internal/config"
"go.b0esche.cloud/backend/internal/database"
"go.b0esche.cloud/backend/pkg/jwt"
)
type Server struct {
*http.Server
}
func New(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, authService *auth.Service, auditLogger *audit.Logger) *Server {
r := NewRouter(cfg, db, jwtManager, authService, auditLogger)
return &Server{
Server: &http.Server{
Addr: cfg.ServerAddr,
Handler: r,
},
}
}

View File

@@ -0,0 +1,123 @@
package middleware
import (
"context"
"net/http"
"strings"
"go.b0esche.cloud/backend/internal/audit"
"go.b0esche.cloud/backend/internal/database"
"go.b0esche.cloud/backend/internal/org"
"go.b0esche.cloud/backend/internal/permission"
"go.b0esche.cloud/backend/pkg/jwt"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/google/uuid"
)
var RequestID = middleware.RequestID
var Logger = middleware.Logger
var Recoverer = middleware.Recoverer
// TODO: Implement rate limiter
var RateLimit = func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Basic rate limiting logic here
next.ServeHTTP(w, r)
})
}
type contextKey string
const (
userKey contextKey = "user"
sessionKey contextKey = "session"
orgKey contextKey = "org"
)
// Auth middleware
func Auth(jwtManager *jwt.Manager, db *database.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
claims, session, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), userKey, claims.UserID)
ctx = context.WithValue(ctx, sessionKey, session)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// Org middleware
func Org(db *database.DB, auditLogger *audit.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userIDStr := r.Context().Value(userKey).(string)
userID, _ := uuid.Parse(userIDStr)
orgIDStr := r.Header.Get("X-Org-ID")
if orgIDStr == "" {
orgIDStr = chi.URLParam(r, "orgId")
}
orgID, err := uuid.Parse(orgIDStr)
if err != nil {
http.Error(w, "Invalid org ID", http.StatusBadRequest)
return
}
_, err = org.CheckMembership(r.Context(), db, userID, orgID)
if err != nil {
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
Action: "org_access",
Success: false,
Metadata: map[string]interface{}{"org_id": orgID, "error": err.Error()},
})
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
ctx := context.WithValue(r.Context(), orgKey, orgID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// Permission middleware
func Permission(db *database.DB, auditLogger *audit.Logger, perm permission.Permission) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userIDStr := r.Context().Value(userKey).(string)
userID, _ := uuid.Parse(userIDStr)
orgID := r.Context().Value(orgKey).(uuid.UUID)
hasPerm, err := permission.HasPermission(r.Context(), db, userID, orgID, perm)
if err != nil || !hasPerm {
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
OrgID: &orgID,
Action: "permission_check",
Resource: &[]string{string(perm)}[0],
Success: false,
Metadata: map[string]interface{}{"permission": perm},
})
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}

View File

@@ -0,0 +1,23 @@
package org
import (
"context"
"go.b0esche.cloud/backend/internal/database"
"github.com/google/uuid"
)
// ResolveUserOrgs returns the organizations a user belongs to
func ResolveUserOrgs(ctx context.Context, db *database.DB, userID uuid.UUID) ([]database.Organization, error) {
return db.GetUserOrganizations(ctx, userID)
}
// CheckMembership checks if user is member of org and returns role
func CheckMembership(ctx context.Context, db *database.DB, userID, orgID uuid.UUID) (string, error) {
membership, err := db.GetUserMembership(ctx, userID, orgID)
if err != nil {
return "", err
}
return membership.Role, nil
}

View File

@@ -0,0 +1,48 @@
package permission
import (
"context"
"fmt"
"go.b0esche.cloud/backend/internal/database"
"github.com/google/uuid"
)
type Permission string
const (
FileRead Permission = "file.read"
FileWrite Permission = "file.write"
FileDelete Permission = "file.delete"
DocumentView Permission = "document.view"
DocumentEdit Permission = "document.edit"
OrgManage Permission = "org.manage"
)
var rolePermissions = map[string][]Permission{
"owner": {FileRead, FileWrite, FileDelete, DocumentView, DocumentEdit, OrgManage},
"admin": {FileRead, FileWrite, FileDelete, DocumentView, DocumentEdit},
"editor": {FileRead, FileWrite, DocumentView, DocumentEdit},
"viewer": {FileRead, DocumentView},
}
// HasPermission checks if user has permission in org
func HasPermission(ctx context.Context, db *database.DB, userID, orgID uuid.UUID, perm Permission) (bool, error) {
membership, err := db.GetUserMembership(ctx, userID, orgID)
if err != nil {
return false, err
}
perms, ok := rolePermissions[membership.Role]
if !ok {
return false, fmt.Errorf("unknown role: %s", membership.Role)
}
for _, p := range perms {
if p == perm {
return true, nil
}
}
return false, nil
}

View File

@@ -0,0 +1,42 @@
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
display_name TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_login_at TIMESTAMP WITH TIME ZONE
);
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE TABLE memberships (
user_id UUID REFERENCES users(id),
org_id UUID REFERENCES organizations(id),
role TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
PRIMARY KEY (user_id, org_id)
);
CREATE TABLE sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
revoked_at TIMESTAMP WITH TIME ZONE
);
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
org_id UUID REFERENCES organizations(id),
action TEXT NOT NULL,
resource TEXT,
success BOOLEAN NOT NULL,
timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
metadata JSONB
);

View File

@@ -0,0 +1,5 @@
DROP TABLE IF EXISTS audit_logs;
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS memberships;
DROP TABLE IF EXISTS organizations;
DROP TABLE IF EXISTS users;

84
go_cloud/pkg/jwt/jwt.go Normal file
View File

@@ -0,0 +1,84 @@
package jwt
import (
"context"
"errors"
"time"
"go.b0esche.cloud/backend/internal/database"
"github.com/golang-jwt/jwt/v4"
"github.com/google/uuid"
)
type Claims struct {
UserID string `json:"user_id"`
OrgIDs []string `json:"org_ids"`
SessionID string `json:"session_id"`
jwt.RegisteredClaims
}
type Manager struct {
secret []byte
}
func NewManager(secret string) *Manager {
return &Manager{secret: []byte(secret)}
}
func (m *Manager) Generate(userID string, orgIDs []string, sessionID string) (string, error) {
claims := Claims{
UserID: userID,
OrgIDs: orgIDs,
SessionID: sessionID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(m.secret)
}
func (m *Manager) Validate(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return m.secret, nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, errors.New("invalid token")
}
func (m *Manager) ValidateWithSession(ctx context.Context, tokenString string, db *database.DB) (*Claims, *database.Session, error) {
claims, err := m.Validate(tokenString)
if err != nil {
return nil, nil, err
}
sessionID, err := uuid.Parse(claims.SessionID)
if err != nil {
return nil, nil, errors.New("invalid session ID in token")
}
session, err := db.GetSession(ctx, sessionID)
if err != nil {
return nil, nil, err
}
if session.RevokedAt != nil || time.Now().After(session.ExpiresAt) {
return nil, nil, errors.New("session invalid")
}
return claims, session, nil
}