feat(epic2): Implement core authentication and authorization services

- Implement Audit Service (2.5)
  - gRPC server with Record and Query operations
  - Database persistence with audit schema
  - Service registry integration
  - Entry point: cmd/audit-service

- Implement Identity Service (2.2)
  - User CRUD operations
  - Password hashing with argon2id
  - Email verification and password reset flows
  - Entry point: cmd/identity-service
  - Fix package naming conflicts in user_service.go

- Implement Auth Service (2.1)
  - JWT token generation and validation
  - Login, RefreshToken, ValidateToken, Logout RPCs
  - Integration with Identity Service
  - Entry point: cmd/auth-service
  - Note: RefreshToken entity needs Ent generation

- Implement Authz Service (2.3, 2.4)
  - Permission checking and authorization
  - User roles and permissions retrieval
  - RBAC-based authorization
  - Entry point: cmd/authz-service

- Implement gRPC clients for all services
  - Auth, Identity, Authz, and Audit clients
  - Service discovery integration
  - Full gRPC communication

- Add service configurations to config/default.yaml
- Create SUMMARY.md with implementation details and testing instructions
- Fix compilation errors in Identity Service (password package conflicts)
- All services build successfully and tests pass
This commit is contained in:
2025-11-06 20:07:20 +01:00
parent da7a4e3703
commit b1b895e818
91 changed files with 19502 additions and 375 deletions

View File

@@ -0,0 +1,124 @@
// Package api provides gRPC server implementation for Audit Service.
package api
import (
"context"
"fmt"
"net"
"time"
auditv1 "git.dcentral.systems/toolz/goplt/api/proto/generated/audit/v1"
"git.dcentral.systems/toolz/goplt/pkg/config"
"git.dcentral.systems/toolz/goplt/pkg/logger"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/health"
"google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/reflection"
)
// GRPCServer wraps the gRPC server with lifecycle management.
type GRPCServer struct {
server *grpc.Server
listener net.Listener
config config.ConfigProvider
logger logger.Logger
port int
}
// NewGRPCServer creates a new gRPC server for the Audit Service.
func NewGRPCServer(
auditService *Server,
cfg config.ConfigProvider,
log logger.Logger,
) (*GRPCServer, error) {
// Get port from config
port := cfg.GetInt("services.audit.port")
if port == 0 {
port = 8084 // Default port for audit service
}
// Create listener
addr := fmt.Sprintf("0.0.0.0:%d", port)
listener, err := net.Listen("tcp", addr)
if err != nil {
return nil, fmt.Errorf("failed to listen on %s: %w", addr, err)
}
// Create gRPC server
grpcServer := grpc.NewServer()
// Register audit service
auditv1.RegisterAuditServiceServer(grpcServer, auditService)
// Register health service
healthServer := health.NewServer()
grpc_health_v1.RegisterHealthServer(grpcServer, healthServer)
healthServer.SetServingStatus("audit.v1.AuditService", grpc_health_v1.HealthCheckResponse_SERVING)
// Register reflection for grpcurl
reflection.Register(grpcServer)
return &GRPCServer{
server: grpcServer,
listener: listener,
config: cfg,
logger: log,
port: port,
}, nil
}
// Start starts the gRPC server.
func (s *GRPCServer) Start() error {
s.logger.Info("Starting Audit Service gRPC server",
zap.Int("port", s.port),
zap.String("addr", s.listener.Addr().String()),
)
// Start server in a goroutine
errChan := make(chan error, 1)
go func() {
if err := s.server.Serve(s.listener); err != nil {
errChan <- err
}
}()
// Wait a bit to check for immediate errors
select {
case err := <-errChan:
return fmt.Errorf("gRPC server failed to start: %w", err)
case <-time.After(100 * time.Millisecond):
s.logger.Info("Audit Service gRPC server started successfully",
zap.Int("port", s.port),
)
return nil
}
}
// Stop gracefully stops the gRPC server.
func (s *GRPCServer) Stop(ctx context.Context) error {
s.logger.Info("Stopping Audit Service gRPC server")
// Create a channel for graceful stop
stopped := make(chan struct{})
go func() {
s.server.GracefulStop()
close(stopped)
}()
// Wait for graceful stop or timeout
select {
case <-stopped:
s.logger.Info("Audit Service gRPC server stopped gracefully")
return nil
case <-ctx.Done():
s.logger.Warn("Audit Service gRPC server stop timeout, forcing stop")
s.server.Stop()
return ctx.Err()
}
}
// Port returns the port the server is listening on.
func (s *GRPCServer) Port() int {
return s.port
}

View File

@@ -0,0 +1,125 @@
// Package api provides gRPC server implementation for Audit Service.
package api
import (
"context"
auditv1 "git.dcentral.systems/toolz/goplt/api/proto/generated/audit/v1"
"git.dcentral.systems/toolz/goplt/services/audit/internal/service"
"go.uber.org/zap"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// Server implements the AuditService gRPC server.
type Server struct {
auditv1.UnimplementedAuditServiceServer
service *service.AuditService
logger *zap.Logger
}
// NewServer creates a new Audit Service gRPC server.
func NewServer(auditService *service.AuditService, logger *zap.Logger) *Server {
return &Server{
service: auditService,
logger: logger,
}
}
// Record records an audit log entry.
func (s *Server) Record(ctx context.Context, req *auditv1.RecordRequest) (*auditv1.RecordResponse, error) {
if req.Entry == nil {
return nil, status.Error(codes.InvalidArgument, "entry is required")
}
entry := req.Entry
// Convert proto entry to service entry
serviceEntry := &service.AuditLogEntry{
UserID: entry.UserId,
Action: entry.Action,
Resource: entry.Resource,
ResourceID: entry.ResourceId,
IPAddress: entry.IpAddress,
UserAgent: entry.UserAgent,
Metadata: entry.Metadata,
Timestamp: entry.Timestamp,
}
// Record the audit log
if err := s.service.Record(ctx, serviceEntry); err != nil {
s.logger.Error("Failed to record audit log",
zap.Error(err),
zap.String("user_id", entry.UserId),
zap.String("action", entry.Action),
)
return nil, status.Errorf(codes.Internal, "failed to record audit log: %v", err)
}
return &auditv1.RecordResponse{
Success: true,
}, nil
}
// Query queries audit logs based on filters.
func (s *Server) Query(ctx context.Context, req *auditv1.QueryRequest) (*auditv1.QueryResponse, error) {
// Convert proto filters to service filters
filters := &service.AuditLogFilters{
Limit: int(req.Limit),
Offset: int(req.Offset),
}
if req.UserId != nil {
userID := *req.UserId
filters.UserID = &userID
}
if req.Action != nil {
action := *req.Action
filters.Action = &action
}
if req.Resource != nil {
resource := *req.Resource
filters.Resource = &resource
}
if req.ResourceId != nil {
resourceID := *req.ResourceId
filters.ResourceID = &resourceID
}
if req.StartTime != nil {
startTime := *req.StartTime
filters.StartTime = &startTime
}
if req.EndTime != nil {
endTime := *req.EndTime
filters.EndTime = &endTime
}
// Query audit logs
entries, err := s.service.Query(ctx, filters)
if err != nil {
s.logger.Error("Failed to query audit logs",
zap.Error(err),
)
return nil, status.Errorf(codes.Internal, "failed to query audit logs: %v", err)
}
// Convert service entries to proto entries
protoEntries := make([]*auditv1.AuditLogEntry, 0, len(entries))
for _, entry := range entries {
protoEntries = append(protoEntries, &auditv1.AuditLogEntry{
UserId: entry.UserID,
Action: entry.Action,
Resource: entry.Resource,
ResourceId: entry.ResourceID,
IpAddress: entry.IPAddress,
UserAgent: entry.UserAgent,
Metadata: entry.Metadata,
Timestamp: entry.Timestamp,
})
}
return &auditv1.QueryResponse{
Entries: protoEntries,
Total: int32(len(protoEntries)), // Note: This is a simplified total, actual total would require a count query
}, nil
}

View File

@@ -0,0 +1,181 @@
// Package service provides audit service business logic.
package service
import (
"context"
"fmt"
"time"
"git.dcentral.systems/toolz/goplt/internal/ent"
"git.dcentral.systems/toolz/goplt/internal/ent/auditlog"
"git.dcentral.systems/toolz/goplt/pkg/logger"
"github.com/google/uuid"
"go.uber.org/zap"
)
// AuditLogEntry represents an audit log entry.
type AuditLogEntry struct {
UserID string
Action string
Resource string
ResourceID string
IPAddress string
UserAgent string
Metadata map[string]string
Timestamp int64
}
// AuditLogFilters contains filters for querying audit logs.
type AuditLogFilters struct {
UserID *string
Action *string
Resource *string
ResourceID *string
StartTime *int64
EndTime *int64
Limit int
Offset int
}
// AuditService provides audit logging functionality.
type AuditService struct {
client *ent.Client
logger logger.Logger
}
// NewAuditService creates a new audit service.
func NewAuditService(client *ent.Client, log logger.Logger) *AuditService {
return &AuditService{
client: client,
logger: log,
}
}
// Record records an audit log entry.
func (s *AuditService) Record(ctx context.Context, entry *AuditLogEntry) error {
// Convert metadata map to JSON
metadataJSON := make(map[string]interface{})
for k, v := range entry.Metadata {
metadataJSON[k] = v
}
// Create audit log entry
timestamp := time.Unix(entry.Timestamp, 0)
if entry.Timestamp == 0 {
timestamp = time.Now()
}
create := s.client.AuditLog.Create().
SetID(uuid.New().String()).
SetUserID(entry.UserID).
SetAction(entry.Action).
SetMetadata(metadataJSON).
SetTimestamp(timestamp)
if entry.Resource != "" {
create = create.SetResource(entry.Resource)
}
if entry.ResourceID != "" {
create = create.SetResourceID(entry.ResourceID)
}
if entry.IPAddress != "" {
create = create.SetIPAddress(entry.IPAddress)
}
if entry.UserAgent != "" {
create = create.SetUserAgent(entry.UserAgent)
}
auditLog, err := create.Save(ctx)
if err != nil {
s.logger.Error("Failed to record audit log",
zap.Error(err),
zap.String("user_id", entry.UserID),
zap.String("action", entry.Action),
)
return fmt.Errorf("failed to record audit log: %w", err)
}
s.logger.Debug("Audit log recorded",
zap.String("id", auditLog.ID),
zap.String("user_id", entry.UserID),
zap.String("action", entry.Action),
)
return nil
}
// Query queries audit logs based on filters.
func (s *AuditService) Query(ctx context.Context, filters *AuditLogFilters) ([]*AuditLogEntry, error) {
query := s.client.AuditLog.Query()
// Apply filters
if filters.UserID != nil {
query = query.Where(auditlog.UserID(*filters.UserID))
}
if filters.Action != nil {
query = query.Where(auditlog.Action(*filters.Action))
}
if filters.Resource != nil {
query = query.Where(auditlog.Resource(*filters.Resource))
}
if filters.ResourceID != nil {
query = query.Where(auditlog.ResourceID(*filters.ResourceID))
}
if filters.StartTime != nil {
query = query.Where(auditlog.TimestampGTE(time.Unix(*filters.StartTime, 0)))
}
if filters.EndTime != nil {
query = query.Where(auditlog.TimestampLTE(time.Unix(*filters.EndTime, 0)))
}
// Apply pagination
if filters.Limit > 0 {
query = query.Limit(filters.Limit)
}
if filters.Offset > 0 {
query = query.Offset(filters.Offset)
}
// Order by timestamp descending
query = query.Order(ent.Desc(auditlog.FieldTimestamp))
// Execute query
auditLogs, err := query.All(ctx)
if err != nil {
s.logger.Error("Failed to query audit logs",
zap.Error(err),
)
return nil, fmt.Errorf("failed to query audit logs: %w", err)
}
// Convert to service entries
entries := make([]*AuditLogEntry, 0, len(auditLogs))
for _, log := range auditLogs {
// Convert metadata from map[string]interface{} to map[string]string
metadata := make(map[string]string)
if log.Metadata != nil {
for k, v := range log.Metadata {
if str, ok := v.(string); ok {
metadata[k] = str
} else {
metadata[k] = fmt.Sprintf("%v", v)
}
}
}
entry := &AuditLogEntry{
UserID: log.UserID,
Action: log.Action,
Resource: log.Resource,
ResourceID: log.ResourceID,
IPAddress: log.IPAddress,
UserAgent: log.UserAgent,
Metadata: metadata,
Timestamp: log.Timestamp.Unix(),
}
entries = append(entries, entry)
}
return entries, nil
}