myCRM/SECURITY_ARCHITECTURE.md
olli 8a132d2fb9 feat: Implement ProjectTask module with full CRUD functionality
- Added ProjectTask entity with fields for name, description, budget, hour contingent, hourly rate, and total price.
- Created ProjectTaskRepository with methods for querying tasks by project and user access.
- Implemented ProjectTaskVoter for fine-grained access control based on user roles and project membership.
- Developed ProjectTaskSecurityListener to enforce permission checks during task creation.
- Introduced custom ProjectTaskProjectFilter for filtering tasks based on project existence.
- Integrated ProjectTask management in the frontend with Vue.js components, including CRUD operations and filtering capabilities.
- Added API endpoints for ProjectTask with appropriate security measures.
- Created migration for project_tasks table in the database.
- Updated documentation to reflect new module features and usage.
2025-11-14 17:12:40 +01:00

19 KiB

Security Architecture - myCRM

Overview

myCRM implements a multi-layered security architecture combining Symfony's authentication/authorization system with custom role-based access control (RBAC), project-based access control, and API Platform integration. The system uses:

  1. Symfony Security System - Authentication, password hashing, user providers
  2. Custom Voters - Fine-grained authorization for entities and modules
  3. API Platform Security - Per-operation security expressions
  4. Event Listeners - Security checks during entity lifecycle
  5. Query Extensions - Automatic query filtering for accessible data

Entity Relationship Model

Core Security Entities

User (users table)
├── id, email, firstName, lastName, password, isActive
├── roles: list<string> (Symfony standard roles like ROLE_ADMIN, ROLE_USER)
├── userRoles: Collection<Role> (custom application roles - ManyToMany)
├── passwordSetupToken, passwordSetupTokenExpiresAt
├── createdAt, lastLoginAt
└── Methods:
    └── hasModulePermission(moduleCode, action): bool

Role (roles table)
├── id, name, description, isSystem (cannot be deleted if true)
├── users: Collection<User> (ManyToMany inverse side)
├── permissions: Collection<RolePermission> (OneToMany)
├── createdAt, updatedAt
└── Constraint: SystemRoleProtection validator prevents system role modification

Module (modules table)
├── id, name, code (unique), description, icon, sortOrder, isActive
├── permissions: Collection<RolePermission> (OneToMany)
└── Purpose: Defines application modules (contacts, projects, project_tasks, etc.)

RolePermission (role_permissions table)
├── id, role_id, module_id (unique constraint: role_id + module_id)
├── canView, canCreate, canEdit, canDelete, canExport, canManage: bool
└── Unique Constraint: Ensures one permission per role-module pair

ModuleAwareInterface (interface)
└── getModuleName(): string (implemented by Contact, Project, ProjectTask)

Project Management Entities

Project (projects table)
├── id, name, description, projectNumber, orderNumber
├── customer: Contact (ManyToOne)
├── status: ProjectStatus (ManyToOne)
├── owner: User (ManyToOne - REQUIRED)
├── teamMembers: Collection<User> (ManyToMany)
├── gitRepositories: Collection<GitRepository> (OneToMany)
├── startDate, endDate, orderDate, budget, hourContingent
├── isPrivate: bool
├── createdAt, updatedAt
├── Implements: ModuleAwareInterface (returns 'projects')
└── Methods:
    ├── hasAccess(user): bool (owner or team member)
    ├── isTeamMember(user): bool
    ├── addTeamMember(user), removeTeamMember(user)
    └── addGitRepository(repo), removeGitRepository(repo)

ProjectTask (project_tasks table)
├── id, name, description
├── project: Project (ManyToOne, nullable)
├── budget, hourContingent, hourlyRate, totalPrice
├── createdAt, updatedAt
├── Implements: ModuleAwareInterface (returns 'project_tasks')
└── Security: Can be project-related OR project-independent (admin-only)

GitRepository (git_repositories table)
├── id, url, localPath, branch, provider, accessToken
├── project: Project (ManyToOne - REQUIRED)
├── name, description
├── lastSync, createdAt, updatedAt
└── Security: Access controlled via associated project

Contact (contacts table)
├── id, companyName, companyNumber, street, zipCode, city, country
├── phone, fax, email, website, taxNumber, vatNumber
├── isDebtor, isCreditor, isActive
├── contactPersons: Collection<ContactPerson> (OneToMany)
├── notes, createdAt, updatedAt
├── Implements: ModuleAwareInterface (returns 'contacts')

ContactPerson (contact_persons table)
├── id, contact_id (ManyToOne, no separate permissions)
├── salutation, title, firstName, lastName, position, department
├── phone, mobile, email, isPrimary
└── Security: Inherits from parent Contact

Security Flow Layers

Layer 1: Authentication

File: config/packages/security.yaml, src/Security/LoginFormAuthenticator.php, src/Security/PocketIdAuthenticator.php

  • Provider: User loaded by email from database
  • Authenticators:
    • LoginFormAuthenticator (form-based login)
    • PocketIdAuthenticator (OAuth/PocketId integration)
  • Password Hashing: Symfony's PasswordHasher (bcrypt)
  • Session: Stateless=false (sessions enabled for web UI)
  • Remember Me: 1-week cookie-based persistence

Layer 2: Base Authorization (Standard Roles)

User has two types of roles:

A. Symfony Standard Roles (stored in roles JSON column)

  • ROLE_USER - Default role for authenticated users
  • ROLE_ADMIN - Full system access (all voters return true)
  • Used in access_control patterns and voter logic

B. Application Roles (stored via user_roles ManyToMany)

  • Custom business roles (e.g., "Vertriebsmitarbeiter", "Betrachter")
  • Linked to modules via RolePermission
  • Define granular per-module actions

Layer 3: Voter-Based Authorization

File: src/Security/Voter/

ModuleVoter

  • Purpose: Controls module-level access (contacts, projects, etc.)
  • Attributes: VIEW, CREATE, EDIT, DELETE, EXPORT, MANAGE
  • Subject Types:
    • String (module code) for collection operations: is_granted('VIEW', 'contacts')
    • Object implementing ModuleAwareInterface for item operations: is_granted('VIEW', $contact)
  • Logic:
    1. If user not logged in → ABSTAIN
    2. If user inactive → DENY
    3. If ROLE_ADMIN → ALLOW (all attributes)
    4. Otherwise → Check user.hasModulePermission(moduleCode, action)
       - Iterates through user roles → role permissions
       - Finds permission for module
       - Checks corresponding canView/canCreate/canEdit/canDelete/canExport/canManage flag
    

ProjectVoter

  • Purpose: Controls project-level access
  • Attributes: VIEW, EDIT, DELETE, CREATE
  • Access Rules:
    • CREATE: Any authenticated user can create projects
    • VIEW: Owner or team member
    • EDIT: Owner or team member
    • DELETE: Owner only
  • Used in: Project API operations

ProjectTaskVoter

  • Purpose: Controls project task access
  • Attributes: VIEW, EDIT, DELETE, CREATE
  • Access Rules:
    • For tasks WITH project: Same as ProjectVoter
    • For tasks WITHOUT project: ROLE_ADMIN only
    • Admin bypass: ROLE_ADMIN can perform all actions
  • Dependency: References associated project's access rules

GitRepositoryVoter

  • Purpose: Controls git repository access
  • Attributes: VIEW, EDIT, DELETE
  • Access Rules: Tied to associated project
    • VIEW/EDIT: Owner or team member of project
    • DELETE: Project owner only
    • No project → DENY all

Layer 4: API Platform Security Expressions

File: Entity classes with #[ApiResource] attributes

API operations define inline security expressions:

#[ApiResource(
    operations: [
        new GetCollection(security: "is_granted('VIEW', 'contacts')"),
        new Get(security: "is_granted('VIEW', object)"),
        new Post(security: "is_granted('CREATE', 'contacts')"),
        new Put(security: "is_granted('EDIT', object)"),
        new Delete(security: "is_granted('DELETE', object)")
    ]
)]

Evaluation:

  1. API request arrives
  2. Security expression evaluated using SecurityExpressionLanguage
  3. If expression returns false → 403 Forbidden
  4. If true → Request processed (entity-level validators still run)

Layer 5: Event Listeners (Doctrine)

File: src/EventListener/ProjectTaskSecurityListener.php

Attribute: #[AsEntityListener(event: Events::prePersist, entity: ProjectTask::class)]

Purpose: Additional security check before persisting ProjectTask

Logic:

if (task.project === null) {
    // Project-independent task
    require ROLE_ADMIN
} else {
    // Project-related task
    allow if user is ROLE_ADMIN or user.hasAccess(project)
}

When it runs: Before flush() during POST/PUT operations

Layer 6: Query Extensions (Collection Filtering)

File: src/Doctrine/Extension/ProjectAccessExtension.php

Implements: QueryCollectionExtensionInterface

Purpose: Automatically filter Project collection queries to accessible projects

Logic:

WHERE project.owner = :current_user 
   OR teamMembers = :current_user

When it runs: Before any Project GetCollection operation

Alternative Approach: src/State/ProjectCollectionProvider.php provides project collection via findUserProjects(user)


Security Implementation Patterns

Pattern 1: Module-Based Permissions

For entities implementing ModuleAwareInterface that track module-level permissions:

File: Contact, Project, ProjectTask entities

  1. Entity implements ModuleAwareInterface::getModuleName() → returns module code
  2. API security expression: is_granted('VIEW', 'contacts')
  3. ModuleVoter checks user.hasModulePermission('contacts', 'view')
  4. User.hasModulePermission() iterates through roles → permissions

Advantage: Centralized permission management in Role/RolePermission tables

Pattern 2: Object-Level Ownership Permissions

For entities with specific owner relationships:

File: Project, ProjectTask, GitRepository

  1. Entity has owner relationship: User $owner
  2. API security expression: is_granted('DELETE', object)
  3. Voter checks: object.getOwner() === user

Advantage: Granular control based on entity relationships

Pattern 3: Project-Based Access Control

For entities related to projects:

File: ProjectTask, GitRepository

  1. Access tied to parent project
  2. Check: project.hasAccess(user) (owner or team member)
  3. Combined with module voter for additional layer

Advantage: Role-based team access to projects and related data

Pattern 4: Admin Bypass

Implemented consistently:

if (in_array('ROLE_ADMIN', $user->getRoles())) {
    return true; // All checks pass
}

File: ModuleVoter, ProjectTaskVoter


API Platform Configuration

File: config/packages/api_platform.yaml

api_platform:
    title: myCRM API
    version: 1.0.0
    defaults:
        stateless: false
        cache_headers:
            vary: ['Content-Type', 'Authorization', 'Origin']
    formats:
        jsonld: ['application/ld+json']
        json: ['application/json']
        html: ['text/html']

Per-Entity API Resource Configuration

Example: Project Entity

#[ApiResource(
    operations: [
        new GetCollection(
            security: "is_granted('VIEW', 'projects')",
            stateless: false
        ),
        new Get(
            security: "is_granted('VIEW', object)",
            stateless: false
        ),
        new Post(
            security: "is_granted('CREATE', 'projects')",
            stateless: false
        ),
        new Put(
            security: "is_granted('EDIT', object)",
            stateless: false
        ),
        new Delete(
            security: "is_granted('DELETE', object)",
            stateless: false
        )
    ],
    normalizationContext: ['groups' => ['project:read']],
    denormalizationContext: ['groups' => ['project:write']],
    order: ['startDate' => 'DESC']
)]
#[ApiFilter(BooleanFilter::class, properties: ['isPrivate'])]
#[ApiFilter(SearchFilter::class, properties: ['name' => 'partial', ...])]
#[ApiFilter(DateFilter::class, properties: ['startDate', 'endDate'])]
class Project implements ModuleAwareInterface { ... }

Serializer Groups

Purpose: Control what fields are returned in API responses based on operation

  • user:read - Fields returned when reading users
  • user:write - Fields accepted when creating/updating users
  • project:read - Fields returned in project responses
  • project:write - Fields accepted in project POST/PUT
  • role:read - Role entity read fields
  • role_permission:read - Permission entity read fields

File: Entity attributes #[Groups(['group:read', 'group:write'])]


Security Checklist During Development

Creating New Module-Based Entities

  1. Implement ModuleAwareInterface

    public function getModuleName(): string { return 'module_code'; }
    
  2. Add Module record to database

    $module = new Module();
    $module->setName('Module Name');
    $module->setCode('module_code');
    
  3. Create API Resource with security

    #[ApiResource(
        operations: [
            new GetCollection(security: "is_granted('VIEW', 'module_code')"),
            new Get(security: "is_granted('VIEW', object)"),
            new Post(security: "is_granted('CREATE', 'module_code')"),
            new Put(security: "is_granted('EDIT', object)"),
            new Delete(security: "is_granted('DELETE', object)")
        ]
    )]
    
  4. Define RolePermissions for existing roles

Adding Project-Based Access to New Entity

  1. Add project relationship

    #[ORM\ManyToOne(targetEntity: Project::class)]
    private ?Project $project = null;
    
  2. Create custom Voter (similar to ProjectTaskVoter)

    private function canView(Entity $entity, User $user): bool {
        if (null === $entity->getProject()) {
            return in_array('ROLE_ADMIN', $user->getRoles());
        }
        return $entity->getProject()->hasAccess($user);
    }
    
  3. Add security expressions to API Resource

    new Get(security: "is_granted('VIEW', object)")
    

Debugging Permission Issues

  1. Check user roles:

    var_dump($this->getUser()->getRoles()); // Symfony standard roles
    var_dump($this->getUser()->getUserRoles()); // Application roles
    
  2. Check module permissions:

    var_dump($user->hasModulePermission('contacts', 'view'));
    
  3. Check voter decision:

    var_dump($this->isGranted('VIEW', 'contacts'));
    var_dump($this->isGranted('VIEW', $contact));
    
  4. Enable security debug in dev toolbar


Voter Decision Flow Diagram

Request arrives for [Attribute, Subject]
  ↓
Symfony checks all registered voters in order
  ↓
Each voter.supports(attribute, subject)?
  ├─ ModuleVoter
  │   ├─ Attribute in [VIEW, CREATE, EDIT, DELETE, EXPORT, MANAGE]?
  │   └─ Subject is string OR implements ModuleAwareInterface?
  │
  ├─ ProjectVoter
  │   ├─ Attribute in [VIEW, EDIT, DELETE, CREATE]?
  │   └─ Subject is 'projects' or Project instance?
  │
  ├─ ProjectTaskVoter
  │   ├─ Attribute in [VIEW, EDIT, DELETE, CREATE]?
  │   └─ Subject is 'project_tasks' or ProjectTask instance?
  │
  ├─ GitRepositoryVoter
  │   ├─ Attribute in [VIEW, EDIT, DELETE]?
  │   └─ Subject is GitRepository instance?
  │
  └─ [Other voters...]
  
  ↓
  
For each voter that supports:
  voter.voteOnAttribute(attribute, subject, token)
  
  Returns:
    ALLOW   (vote: YES)  → Stop here, GRANT access
    DENY    (vote: NO)   → Stop here, DENY access
    ABSTAIN (vote: null) → Continue to next voter
  
  ↓
  
Default if all ABSTAIN: DENY (secure by default)

Files Organization

Security Components

src/Security/
├── Voter/
│   ├── ModuleVoter.php (Module-level access)
│   ├── ProjectVoter.php (Project ownership/team)
│   ├── ProjectTaskVoter.php (Project-based + admin)
│   └── GitRepositoryVoter.php (Project-based)
├── LoginFormAuthenticator.php
└── PocketIdAuthenticator.php

Entities

src/Entity/
├── User.php (Authentication + module permissions)
├── Role.php (Application role definition)
├── RolePermission.php (Role-module permission mapping)
├── Module.php (Application module registry)
├── Interface/
│   └── ModuleAwareInterface.php
├── Project.php (Owner + team members)
├── ProjectTask.php (Optional project association)
├── GitRepository.php (Project association)
├── Contact.php (Module-aware)
├── ContactPerson.php (No separate permissions)
└── ProjectStatus.php

Event Listeners

src/EventListener/
├── ProjectTaskSecurityListener.php (prePersist validation)
├── LoginSuccessListener.php
├── GitRepositoryCacheListener.php
└── [Others...]

Query Extensions

src/Doctrine/Extension/
└── ProjectAccessExtension.php (Auto-filter GetCollection)

API Filters

src/Filter/
└── ProjectTaskProjectFilter.php (hasProject filter)

Configuration

config/
├── packages/
│   ├── security.yaml (Firewall, authentication, access_control)
│   ├── api_platform.yaml (API Platform defaults)
│   └── [Others...]
└── services.yaml (Service wiring)

Default Access Control Rules

From config/packages/security.yaml:

access_control:
    - { path: ^/login, roles: PUBLIC_ACCESS }
    - { path: ^/password-reset, roles: PUBLIC_ACCESS }
    - { path: ^/password-setup, roles: PUBLIC_ACCESS }
    - { path: ^/connect/pocketid, roles: PUBLIC_ACCESS }
    - { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
    - { path: ^/, roles: ROLE_USER }
  • /api/* requires full authentication (not remember_me)
  • Other app routes require ROLE_USER or ROLE_ADMIN
  • Public routes for auth bypass

Testing Security

Unit Testing Voters

public function testProjectVoterDenyNonOwner(): void {
    $user = new User();
    $owner = new User();
    $project = new Project();
    $project->setOwner($owner);
    
    $token = $this->createToken($user);
    $voter = new ProjectVoter();
    
    $this->assertEquals(
        Voter::DENY,
        $voter->vote($token, $project, [ProjectVoter::DELETE])
    );
}

API Integration Testing

public function testDeleteProjectForbiddenForNonOwner(): void {
    $this->client->request('DELETE', '/api/projects/1');
    $this->assertResponseStatusCodeSame(403);
}

Common Issues & Solutions

Issue Cause Solution
403 on API call Missing module permission Check RolePermission for user's roles
Admin can't access entity Voter logic error Verify ROLE_ADMIN bypass in voter
Project team member denied hasAccess() returns false Check project.owner or project.teamMembers
Query returns all items ProjectAccessExtension not applied Verify entity is Project class
Permission check always fails Wrong attribute/subject format Use is_granted('VIEW', 'module_code') for strings, is_granted('VIEW', $object) for objects

References