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

@@ -5,29 +5,129 @@ import (
"context"
"fmt"
auditv1 "git.dcentral.systems/toolz/goplt/api/proto/generated/audit/v1"
"git.dcentral.systems/toolz/goplt/pkg/registry"
"git.dcentral.systems/toolz/goplt/pkg/services"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// AuditClient implements AuditServiceClient using gRPC.
// This is a stub implementation - will be fully implemented when proto files are generated in Phase 4.
type AuditClient struct {
registry registry.ServiceRegistry
conn *grpc.ClientConn
client auditv1.AuditServiceClient
}
// NewAuditClient creates a new gRPC client for the Audit Service.
func NewAuditClient(reg registry.ServiceRegistry) (services.AuditServiceClient, error) {
return &AuditClient{
client := &AuditClient{
registry: reg,
}, nil
}
return client, nil
}
// connect connects to the Audit Service.
func (c *AuditClient) connect(ctx context.Context) error {
if c.conn != nil {
return nil
}
instances, err := c.registry.Discover(ctx, "audit-service")
if err != nil {
return fmt.Errorf("failed to discover audit service: %w", err)
}
if len(instances) == 0 {
return fmt.Errorf("no instances found for audit-service")
}
instance := instances[0]
address := fmt.Sprintf("%s:%d", instance.Address, instance.Port)
conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return fmt.Errorf("failed to connect to audit-service at %s: %w", address, err)
}
c.conn = conn
c.client = auditv1.NewAuditServiceClient(conn)
return nil
}
// Record records an audit log entry.
func (c *AuditClient) Record(ctx context.Context, entry *services.AuditLogEntry) error {
return fmt.Errorf("not implemented: proto files not yet generated")
if err := c.connect(ctx); err != nil {
return err
}
_, err := c.client.Record(ctx, &auditv1.RecordRequest{
Entry: &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,
},
})
if err != nil {
return fmt.Errorf("record audit log failed: %w", err)
}
return nil
}
// Query queries audit logs based on filters.
func (c *AuditClient) Query(ctx context.Context, filters *services.AuditLogFilters) ([]services.AuditLogEntry, error) {
return nil, fmt.Errorf("not implemented: proto files not yet generated")
if err := c.connect(ctx); err != nil {
return nil, err
}
req := &auditv1.QueryRequest{
Limit: int32(filters.Limit),
Offset: int32(filters.Offset),
}
if filters.UserID != nil {
req.UserId = filters.UserID
}
if filters.Action != nil {
req.Action = filters.Action
}
if filters.Resource != nil {
req.Resource = filters.Resource
}
if filters.ResourceID != nil {
req.ResourceId = filters.ResourceID
}
if filters.StartTime != nil {
req.StartTime = filters.StartTime
}
if filters.EndTime != nil {
req.EndTime = filters.EndTime
}
resp, err := c.client.Query(ctx, req)
if err != nil {
return nil, fmt.Errorf("query audit logs failed: %w", err)
}
entries := make([]services.AuditLogEntry, 0, len(resp.Entries))
for _, e := range resp.Entries {
entries = append(entries, services.AuditLogEntry{
UserID: e.UserId,
Action: e.Action,
Resource: e.Resource,
ResourceID: e.ResourceId,
IPAddress: e.IpAddress,
UserAgent: e.UserAgent,
Metadata: e.Metadata,
Timestamp: e.Timestamp,
})
}
return entries, nil
}

View File

@@ -5,70 +5,132 @@ import (
"context"
"fmt"
authv1 "git.dcentral.systems/toolz/goplt/api/proto/generated/auth/v1"
"git.dcentral.systems/toolz/goplt/pkg/registry"
"git.dcentral.systems/toolz/goplt/pkg/services"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// AuthClient implements AuthServiceClient using gRPC.
// This is a stub implementation - will be fully implemented when proto files are generated in Phase 4.
type AuthClient struct {
registry registry.ServiceRegistry
// conn will be set when proto files are available
// conn *grpc.ClientConn
conn *grpc.ClientConn
client authv1.AuthServiceClient
}
// NewAuthClient creates a new gRPC client for the Auth Service.
func NewAuthClient(reg registry.ServiceRegistry) (services.AuthServiceClient, error) {
return &AuthClient{
client := &AuthClient{
registry: reg,
}, nil
}
return client, nil
}
// connect connects to the Auth Service.
func (c *AuthClient) connect(ctx context.Context) error {
if c.conn != nil {
return nil
}
instances, err := c.registry.Discover(ctx, "auth-service")
if err != nil {
return fmt.Errorf("failed to discover auth service: %w", err)
}
if len(instances) == 0 {
return fmt.Errorf("no instances found for auth-service")
}
instance := instances[0]
address := fmt.Sprintf("%s:%d", instance.Address, instance.Port)
conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return fmt.Errorf("failed to connect to auth-service at %s: %w", address, err)
}
c.conn = conn
c.client = authv1.NewAuthServiceClient(conn)
return nil
}
// Login authenticates a user and returns access and refresh tokens.
func (c *AuthClient) Login(ctx context.Context, email, password string) (*services.TokenResponse, error) {
// TODO: Implement when proto files are generated
return nil, fmt.Errorf("not implemented: proto files not yet generated")
if err := c.connect(ctx); err != nil {
return nil, err
}
resp, err := c.client.Login(ctx, &authv1.LoginRequest{
Email: email,
Password: password,
})
if err != nil {
return nil, fmt.Errorf("login failed: %w", err)
}
return &services.TokenResponse{
AccessToken: resp.AccessToken,
RefreshToken: resp.RefreshToken,
ExpiresIn: resp.ExpiresIn,
TokenType: resp.TokenType,
}, nil
}
// RefreshToken refreshes an access token using a refresh token.
func (c *AuthClient) RefreshToken(ctx context.Context, refreshToken string) (*services.TokenResponse, error) {
// TODO: Implement when proto files are generated
return nil, fmt.Errorf("not implemented: proto files not yet generated")
if err := c.connect(ctx); err != nil {
return nil, err
}
resp, err := c.client.RefreshToken(ctx, &authv1.RefreshTokenRequest{
RefreshToken: refreshToken,
})
if err != nil {
return nil, fmt.Errorf("refresh token failed: %w", err)
}
return &services.TokenResponse{
AccessToken: resp.AccessToken,
RefreshToken: resp.RefreshToken,
ExpiresIn: resp.ExpiresIn,
TokenType: resp.TokenType,
}, nil
}
// ValidateToken validates a JWT token and returns the token claims.
func (c *AuthClient) ValidateToken(ctx context.Context, token string) (*services.TokenClaims, error) {
// TODO: Implement when proto files are generated
return nil, fmt.Errorf("not implemented: proto files not yet generated")
if err := c.connect(ctx); err != nil {
return nil, err
}
resp, err := c.client.ValidateToken(ctx, &authv1.ValidateTokenRequest{
Token: token,
})
if err != nil {
return nil, fmt.Errorf("validate token failed: %w", err)
}
return &services.TokenClaims{
UserID: resp.UserId,
Email: resp.Email,
Roles: resp.Roles,
ExpiresAt: resp.ExpiresAt,
}, nil
}
// Logout invalidates a refresh token.
func (c *AuthClient) Logout(ctx context.Context, refreshToken string) error {
// TODO: Implement when proto files are generated
return fmt.Errorf("not implemented: proto files not yet generated")
}
if err := c.connect(ctx); err != nil {
return err
}
// TODO: connectToService will be implemented when proto files are generated
// This function will discover and connect to a service instance via gRPC.
// func connectToService(ctx context.Context, reg registry.ServiceRegistry, serviceName string) (*grpc.ClientConn, error) {
// instances, err := reg.Discover(ctx, serviceName)
// if err != nil {
// return nil, fmt.Errorf("failed to discover service %s: %w", serviceName, err)
// }
//
// if len(instances) == 0 {
// return nil, fmt.Errorf("no instances found for service %s", serviceName)
// }
//
// // Use the first healthy instance (load balancing can be added later)
// instance := instances[0]
// address := fmt.Sprintf("%s:%d", instance.Address, instance.Port)
//
// // Create gRPC connection
// conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials()))
// if err != nil {
// return nil, fmt.Errorf("failed to connect to %s at %s: %w", serviceName, address, err)
// }
//
// return conn, nil
// }
_, err := c.client.Logout(ctx, &authv1.LogoutRequest{
RefreshToken: refreshToken,
})
if err != nil {
return fmt.Errorf("logout failed: %w", err)
}
return nil
}

View File

@@ -5,39 +5,142 @@ import (
"context"
"fmt"
authzv1 "git.dcentral.systems/toolz/goplt/api/proto/generated/authz/v1"
"git.dcentral.systems/toolz/goplt/pkg/registry"
"git.dcentral.systems/toolz/goplt/pkg/services"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// AuthzClient implements AuthzServiceClient using gRPC.
// This is a stub implementation - will be fully implemented when proto files are generated in Phase 4.
type AuthzClient struct {
registry registry.ServiceRegistry
conn *grpc.ClientConn
client authzv1.AuthzServiceClient
}
// NewAuthzClient creates a new gRPC client for the Authz Service.
func NewAuthzClient(reg registry.ServiceRegistry) (services.AuthzServiceClient, error) {
return &AuthzClient{
client := &AuthzClient{
registry: reg,
}, nil
}
return client, nil
}
// connect connects to the Authz Service.
func (c *AuthzClient) connect(ctx context.Context) error {
if c.conn != nil {
return nil
}
instances, err := c.registry.Discover(ctx, "authz-service")
if err != nil {
return fmt.Errorf("failed to discover authz service: %w", err)
}
if len(instances) == 0 {
return fmt.Errorf("no instances found for authz-service")
}
instance := instances[0]
address := fmt.Sprintf("%s:%d", instance.Address, instance.Port)
conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return fmt.Errorf("failed to connect to authz-service at %s: %w", address, err)
}
c.conn = conn
c.client = authzv1.NewAuthzServiceClient(conn)
return nil
}
// Authorize checks if a user has a specific permission and returns an error if not.
func (c *AuthzClient) Authorize(ctx context.Context, userID, permission string) error {
return fmt.Errorf("not implemented: proto files not yet generated")
if err := c.connect(ctx); err != nil {
return err
}
resp, err := c.client.Authorize(ctx, &authzv1.AuthorizeRequest{
UserId: userID,
Permission: permission,
})
if err != nil {
return fmt.Errorf("authorize failed: %w", err)
}
if !resp.Authorized {
return fmt.Errorf("unauthorized: %s", resp.Message)
}
return nil
}
// HasPermission checks if a user has a specific permission.
func (c *AuthzClient) HasPermission(ctx context.Context, userID, permission string) (bool, error) {
return false, fmt.Errorf("not implemented: proto files not yet generated")
if err := c.connect(ctx); err != nil {
return false, err
}
resp, err := c.client.HasPermission(ctx, &authzv1.HasPermissionRequest{
UserId: userID,
Permission: permission,
})
if err != nil {
return false, fmt.Errorf("has permission check failed: %w", err)
}
return resp.HasPermission, nil
}
// GetUserPermissions returns all permissions for a user.
func (c *AuthzClient) GetUserPermissions(ctx context.Context, userID string) ([]services.Permission, error) {
return nil, fmt.Errorf("not implemented: proto files not yet generated")
if err := c.connect(ctx); err != nil {
return nil, err
}
resp, err := c.client.GetUserPermissions(ctx, &authzv1.GetUserPermissionsRequest{
UserId: userID,
})
if err != nil {
return nil, fmt.Errorf("get user permissions failed: %w", err)
}
permissions := make([]services.Permission, 0, len(resp.Permissions))
for _, p := range resp.Permissions {
permissions = append(permissions, services.Permission{
ID: p.Id,
Code: p.Code,
Name: p.Name,
Description: p.Description,
})
}
return permissions, nil
}
// GetUserRoles returns all roles for a user.
func (c *AuthzClient) GetUserRoles(ctx context.Context, userID string) ([]services.Role, error) {
return nil, fmt.Errorf("not implemented: proto files not yet generated")
if err := c.connect(ctx); err != nil {
return nil, err
}
resp, err := c.client.GetUserRoles(ctx, &authzv1.GetUserRolesRequest{
UserId: userID,
})
if err != nil {
return nil, fmt.Errorf("get user roles failed: %w", err)
}
roles := make([]services.Role, 0, len(resp.Roles))
for _, r := range resp.Roles {
roles = append(roles, services.Role{
ID: r.Id,
Name: r.Name,
Description: r.Description,
Permissions: r.Permissions,
})
}
return roles, nil
}

View File

@@ -5,59 +5,210 @@ import (
"context"
"fmt"
identityv1 "git.dcentral.systems/toolz/goplt/api/proto/generated/identity/v1"
"git.dcentral.systems/toolz/goplt/pkg/registry"
"git.dcentral.systems/toolz/goplt/pkg/services"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// IdentityClient implements IdentityServiceClient using gRPC.
// This is a stub implementation - will be fully implemented when proto files are generated in Phase 4.
type IdentityClient struct {
registry registry.ServiceRegistry
conn *grpc.ClientConn
client identityv1.IdentityServiceClient
}
// NewIdentityClient creates a new gRPC client for the Identity Service.
func NewIdentityClient(reg registry.ServiceRegistry) (services.IdentityServiceClient, error) {
return &IdentityClient{
client := &IdentityClient{
registry: reg,
}, nil
}
return client, nil
}
// connect connects to the Identity Service.
func (c *IdentityClient) connect(ctx context.Context) error {
if c.conn != nil {
return nil
}
instances, err := c.registry.Discover(ctx, "identity-service")
if err != nil {
return fmt.Errorf("failed to discover identity service: %w", err)
}
if len(instances) == 0 {
return fmt.Errorf("no instances found for identity-service")
}
instance := instances[0]
address := fmt.Sprintf("%s:%d", instance.Address, instance.Port)
conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return fmt.Errorf("failed to connect to identity-service at %s: %w", address, err)
}
c.conn = conn
c.client = identityv1.NewIdentityServiceClient(conn)
return nil
}
// GetUser retrieves a user by ID.
func (c *IdentityClient) GetUser(ctx context.Context, id string) (*services.User, error) {
return nil, fmt.Errorf("not implemented: proto files not yet generated")
if err := c.connect(ctx); err != nil {
return nil, err
}
resp, err := c.client.GetUser(ctx, &identityv1.GetUserRequest{Id: id})
if err != nil {
return nil, fmt.Errorf("get user failed: %w", err)
}
return protoUserToServiceUser(resp.User), nil
}
// GetUserByEmail retrieves a user by email address.
func (c *IdentityClient) GetUserByEmail(ctx context.Context, email string) (*services.User, error) {
return nil, fmt.Errorf("not implemented: proto files not yet generated")
if err := c.connect(ctx); err != nil {
return nil, err
}
resp, err := c.client.GetUserByEmail(ctx, &identityv1.GetUserByEmailRequest{Email: email})
if err != nil {
return nil, fmt.Errorf("get user by email failed: %w", err)
}
return protoUserToServiceUser(resp.User), nil
}
// CreateUser creates a new user.
func (c *IdentityClient) CreateUser(ctx context.Context, user *services.CreateUserRequest) (*services.User, error) {
return nil, fmt.Errorf("not implemented: proto files not yet generated")
if err := c.connect(ctx); err != nil {
return nil, err
}
resp, err := c.client.CreateUser(ctx, &identityv1.CreateUserRequest{
Email: user.Email,
Username: user.Username,
Password: user.Password,
FirstName: user.FirstName,
LastName: user.LastName,
})
if err != nil {
return nil, fmt.Errorf("create user failed: %w", err)
}
return protoUserToServiceUser(resp.User), nil
}
// UpdateUser updates an existing user.
func (c *IdentityClient) UpdateUser(ctx context.Context, id string, user *services.UpdateUserRequest) (*services.User, error) {
return nil, fmt.Errorf("not implemented: proto files not yet generated")
if err := c.connect(ctx); err != nil {
return nil, err
}
var email, username, firstName, lastName *string
if user.Email != nil && *user.Email != "" {
email = user.Email
}
if user.Username != nil && *user.Username != "" {
username = user.Username
}
if user.FirstName != nil && *user.FirstName != "" {
firstName = user.FirstName
}
if user.LastName != nil && *user.LastName != "" {
lastName = user.LastName
}
resp, err := c.client.UpdateUser(ctx, &identityv1.UpdateUserRequest{
Id: id,
Email: email,
Username: username,
FirstName: firstName,
LastName: lastName,
})
if err != nil {
return nil, fmt.Errorf("update user failed: %w", err)
}
return protoUserToServiceUser(resp.User), nil
}
// DeleteUser deletes a user.
func (c *IdentityClient) DeleteUser(ctx context.Context, id string) error {
return fmt.Errorf("not implemented: proto files not yet generated")
if err := c.connect(ctx); err != nil {
return err
}
_, err := c.client.DeleteUser(ctx, &identityv1.DeleteUserRequest{Id: id})
if err != nil {
return fmt.Errorf("delete user failed: %w", err)
}
return nil
}
// VerifyEmail verifies a user's email address using a verification token.
func (c *IdentityClient) VerifyEmail(ctx context.Context, token string) error {
return fmt.Errorf("not implemented: proto files not yet generated")
if err := c.connect(ctx); err != nil {
return err
}
_, err := c.client.VerifyEmail(ctx, &identityv1.VerifyEmailRequest{Token: token})
if err != nil {
return fmt.Errorf("verify email failed: %w", err)
}
return nil
}
// RequestPasswordReset requests a password reset token.
func (c *IdentityClient) RequestPasswordReset(ctx context.Context, email string) error {
return fmt.Errorf("not implemented: proto files not yet generated")
if err := c.connect(ctx); err != nil {
return err
}
_, err := c.client.RequestPasswordReset(ctx, &identityv1.RequestPasswordResetRequest{Email: email})
if err != nil {
return fmt.Errorf("request password reset failed: %w", err)
}
return nil
}
// ResetPassword resets a user's password using a reset token.
func (c *IdentityClient) ResetPassword(ctx context.Context, token, newPassword string) error {
return fmt.Errorf("not implemented: proto files not yet generated")
if err := c.connect(ctx); err != nil {
return err
}
_, err := c.client.ResetPassword(ctx, &identityv1.ResetPasswordRequest{
Token: token,
NewPassword: newPassword,
})
if err != nil {
return fmt.Errorf("reset password failed: %w", err)
}
return nil
}
// protoUserToServiceUser converts a proto User to a service User.
func protoUserToServiceUser(u *identityv1.User) *services.User {
if u == nil {
return nil
}
return &services.User{
ID: u.Id,
Email: u.Email,
Username: u.Username,
FirstName: u.FirstName,
LastName: u.LastName,
EmailVerified: u.EmailVerified,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
}