go first commit
This commit is contained in:
14
go_cloud/.env.example
Normal file
14
go_cloud/.env.example
Normal 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
16
go_cloud/Makefile
Normal 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
113
go_cloud/README.md
Normal 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
BIN
go_cloud/api
Executable file
Binary file not shown.
BIN
go_cloud/bin/api
Executable file
BIN
go_cloud/bin/api
Executable file
Binary file not shown.
43
go_cloud/cmd/api/main.go
Normal file
43
go_cloud/cmd/api/main.go
Normal 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
23
go_cloud/go.mod
Normal 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
35
go_cloud/go.sum
Normal 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=
|
||||||
44
go_cloud/internal/audit/audit.go
Normal file
44
go_cloud/internal/audit/audit.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
96
go_cloud/internal/auth/auth.go
Normal file
96
go_cloud/internal/auth/auth.go
Normal 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
|
||||||
|
}
|
||||||
29
go_cloud/internal/auth/auth_test.go
Normal file
29
go_cloud/internal/auth/auth_test.go
Normal 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")
|
||||||
|
}
|
||||||
34
go_cloud/internal/config/config.go
Normal file
34
go_cloud/internal/config/config.go
Normal 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
|
||||||
|
}
|
||||||
29
go_cloud/internal/database/database.go
Normal file
29
go_cloud/internal/database/database.go
Normal 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
|
||||||
|
}
|
||||||
124
go_cloud/internal/database/db.go
Normal file
124
go_cloud/internal/database/db.go
Normal 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
|
||||||
|
}
|
||||||
93
go_cloud/internal/http/routes.go
Normal file
93
go_cloud/internal/http/routes.go
Normal 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 + `"}`))
|
||||||
|
}
|
||||||
26
go_cloud/internal/http/server.go
Normal file
26
go_cloud/internal/http/server.go
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
123
go_cloud/internal/middleware/middleware.go
Normal file
123
go_cloud/internal/middleware/middleware.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
23
go_cloud/internal/org/org.go
Normal file
23
go_cloud/internal/org/org.go
Normal 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
|
||||||
|
}
|
||||||
48
go_cloud/internal/permission/permission.go
Normal file
48
go_cloud/internal/permission/permission.go
Normal 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
|
||||||
|
}
|
||||||
42
go_cloud/migrations/0001_initial.sql
Normal file
42
go_cloud/migrations/0001_initial.sql
Normal 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
|
||||||
|
);
|
||||||
5
go_cloud/migrations/0001_initial_down.sql
Normal file
5
go_cloud/migrations/0001_initial_down.sql
Normal 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
84
go_cloud/pkg/jwt/jwt.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user