Data governance — совокупность процессов, политик и стандартов для управления данными в организации. Включает: кто владеет данными, как они классифицированы, кто имеет доступ, как долго хранятся.
Ключевые регуляции
Регуляция
Сфера
Регион
Штрафы
GDPR
Персональные данные
EU/EEA
До 4% годового оборота
PCI DSS
Данные платёжных карт
Глобально
Штрафы + потеря права принимать платежи
HIPAA
Медицинские данные
США
До $1.5M за категорию нарушения
SOC 2
Безопасность сервисов
Глобально
Потеря доверия клиентов
CCPA
Персональные данные
Калифорния
$2,500-$7,500 за нарушение
Классификация данных
Уровень
Описание
Примеры
Меры защиты
Public
Общедоступные
Маркетинговые материалы
Минимальные
Internal
Внутренние
Внутренние документы
Контроль доступа
Confidential
Конфиденциальные
Финансы, контракты
Шифрование + ACL
Restricted
Строго ограниченные
PII, данные карт, медицина
Шифрование + аудит + MFA
GDPR: ключевые требования
Права субъектов данных
Право
Описание
Техническая реализация
Right to Access
Получить свои данные
Data export API
Right to Rectification
Исправить данные
Edit API
Right to Erasure
Удалить данные
Data deletion pipeline
Right to Portability
Перенести данные
Export в стандартном формате
Right to Object
Отказ от обработки
Consent management
Реализация GDPR Data Export
<?php
declare(strict_types=1);
namespace App\Privacy;
final readonly class GdprDataExporter
{
public function __construct(
private UserRepository $users,
private OrderRepository $orders,
private ActivityLogRepository $activityLogs,
) {}
/**
* Export all personal data for a user (GDPR Article 20).
*
* @return array<string, mixed> Machine-readable personal data
*/
public function exportUserData(string $userId): array
{
$user = $this->users->findById($userId);
if ($user === null) {
throw new \RuntimeException('User not found');
}
return [
'export_metadata' => [
'exported_at' => (new \DateTimeImmutable())->format(\DATE_ATOM),
'format_version' => '1.0',
'data_controller' => 'Example Company Ltd.',
],
'personal_information' => [
'name' => $user->getName(),
'email' => $user->getEmail(),
'phone' => $user->getPhone(),
'address' => $user->getAddress(),
'registered_at' => $user->getCreatedAt()->format(\DATE_ATOM),
],
'orders' => $this->exportOrders($userId),
'activity_log' => $this->exportActivityLog($userId),
'consents' => $this->exportConsents($userId),
];
}
/**
* Delete all personal data (GDPR Article 17 — Right to Erasure).
*/
public function deleteUserData(string $userId): DeletionReport
{
$deletedItems = [];
// Anonymize orders (keep for accounting, remove PII)
$orderCount = $this->orders->anonymizeByUser($userId);
$deletedItems['orders_anonymized'] = $orderCount;
// Delete activity logs
$logCount = $this->activityLogs->deleteByUser($userId);
$deletedItems['activity_logs_deleted'] = $logCount;
// Delete user account
$this->users->delete($userId);
$deletedItems['user_deleted'] = true;
return new DeletionReport(
userId: $userId,
deletedAt: new \DateTimeImmutable(),
items: $deletedItems,
);
}
private function exportOrders(string $userId): array
{
$orders = $this->orders->findByUser($userId);
return array_map(static fn($order) => [
'order_id' => $order->getId(),
'date' => $order->getCreatedAt()->format(\DATE_ATOM),
'total' => $order->getTotal(),
'status' => $order->getStatus(),
], $orders);
}
private function exportActivityLog(string $userId): array
{
return $this->activityLogs->findByUser($userId, limit: 1000);
}
private function exportConsents(string $userId): array
{
// Return all consent records
return [];
}
}
final readonly class DeletionReport
{
public function __construct(
public string $userId,
public \DateTimeImmutable $deletedAt,
public array $items,
) {}
}
package privacy
import (
"context"
"fmt"
"time"
)
// GDPRExporter handles GDPR data export and erasure.
type GDPRExporter struct {
users UserRepository
orders OrderRepository
activity ActivityLogRepository
}
// ExportUserData exports all personal data for a user (GDPR Article 20).
func (e *GDPRExporter) ExportUserData(ctx context.Context, userID string) (map[string]any, error) {
user, err := e.users.FindByID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("find user: %w", err)
}
// Every error is propagated: a silently truncated export would answer a
// subject access request with incomplete data, which is itself a breach.
orders, err := e.orders.FindByUser(ctx, userID)
if err != nil {
return nil, fmt.Errorf("export orders: %w", err)
}
logs, err := e.activity.FindByUser(ctx, userID, 1000)
if err != nil {
return nil, fmt.Errorf("export activity log: %w", err)
}
return map[string]any{
"export_metadata": map[string]any{
"exported_at": time.Now().Format(time.RFC3339),
"format_version": "1.0",
"data_controller": "Example Company Ltd.",
},
"personal_information": map[string]any{
"name": user.Name,
"email": user.Email,
"phone": user.Phone,
"address": user.Address,
"registered_at": user.CreatedAt.Format(time.RFC3339),
},
"orders": orders,
"activity_log": logs,
}, nil
}
// DeleteUserData erases all personal data (GDPR Article 17).
func (e *GDPRExporter) DeleteUserData(ctx context.Context, userID string) (map[string]int, error) {
result := make(map[string]int)
// Anonymize orders (kept for accounting, PII stripped). A swallowed error
// here would report an erasure that never happened, leaving personal data
// behind while the audit trail says it is gone.
anonymized, err := e.orders.AnonymizeByUser(ctx, userID)
if err != nil {
return nil, fmt.Errorf("anonymize orders: %w", err)
}
result["orders_anonymized"] = anonymized
deletedLogs, err := e.activity.DeleteByUser(ctx, userID)
if err != nil {
return nil, fmt.Errorf("delete activity logs: %w", err)
}
result["activity_logs_deleted"] = deletedLogs
if err := e.users.Delete(ctx, userID); err != nil {
return nil, fmt.Errorf("delete user: %w", err)
}
result["user_deleted"] = 1
return result, nil
}
namespace App.Privacy;
public sealed record DeletionReport(
string UserId,
DateTimeOffset DeletedAt,
IReadOnlyDictionary<string, int> Items);
public sealed class GdprDataExporter(
IUserRepository users,
IOrderRepository orders,
IActivityLogRepository activityLogs)
{
// Export all personal data for a user (GDPR Article 20).
public async Task<IReadOnlyDictionary<string, object>> ExportUserDataAsync(
string userId,
CancellationToken ct = default)
{
User user = await users.FindByIdAsync(userId, ct)
?? throw new InvalidOperationException("User not found");
IReadOnlyList<Order> userOrders = await orders.FindByUserAsync(userId, ct);
IReadOnlyList<ActivityLogEntry> logs = await activityLogs.FindByUserAsync(userId, 1000, ct);
return new Dictionary<string, object>
{
["export_metadata"] = new Dictionary<string, object>
{
["exported_at"] = DateTimeOffset.UtcNow.ToString("O"),
["format_version"] = "1.0",
["data_controller"] = "Example Company Ltd.",
},
["personal_information"] = new Dictionary<string, object?>
{
["name"] = user.Name,
["email"] = user.Email,
["phone"] = user.Phone,
["address"] = user.Address,
["registered_at"] = user.CreatedAt.ToString("O"),
},
["orders"] = userOrders.Select(o => new
{
order_id = o.Id,
date = o.CreatedAt.ToString("O"),
total = o.Total,
status = o.Status,
}).ToList(),
["activity_log"] = logs,
};
}
// Erase all personal data (GDPR Article 17 — Right to Erasure).
public async Task<DeletionReport> DeleteUserDataAsync(
string userId,
CancellationToken ct = default)
{
var deleted = new Dictionary<string, int>();
// Anonymize orders (kept for accounting, PII stripped).
deleted["orders_anonymized"] = await orders.AnonymizeByUserAsync(userId, ct);
deleted["activity_logs_deleted"] = await activityLogs.DeleteByUserAsync(userId, ct);
await users.DeleteAsync(userId, ct);
deleted["user_deleted"] = 1;
return new DeletionReport(userId, DateTimeOffset.UtcNow, deleted);
}
}
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
@dataclass(frozen=True, slots=True)
class DeletionReport:
user_id: str
deleted_at: datetime
items: dict[str, int]
class UserNotFoundError(LookupError):
"""Raised when the subject of a GDPR request does not exist."""
class GdprDataExporter:
def __init__(
self,
users: UserRepository,
orders: OrderRepository,
activity_logs: ActivityLogRepository,
) -> None:
self._users = users
self._orders = orders
self._activity_logs = activity_logs
async def export_user_data(self, user_id: str) -> dict[str, Any]:
"""Export all personal data for a user (GDPR Article 20)."""
user = await self._users.find_by_id(user_id)
if user is None:
raise UserNotFoundError(user_id)
orders = await self._orders.find_by_user(user_id)
logs = await self._activity_logs.find_by_user(user_id, limit=1000)
return {
"export_metadata": {
"exported_at": datetime.now(timezone.utc).isoformat(),
"format_version": "1.0",
"data_controller": "Example Company Ltd.",
},
"personal_information": {
"name": user.name,
"email": user.email,
"phone": user.phone,
"address": user.address,
"registered_at": user.created_at.isoformat(),
},
"orders": [
{
"order_id": order.id,
"date": order.created_at.isoformat(),
"total": order.total,
"status": order.status,
}
for order in orders
],
"activity_log": logs,
}
async def delete_user_data(self, user_id: str) -> DeletionReport:
"""Erase all personal data (GDPR Article 17 — Right to Erasure)."""
deleted: dict[str, int] = {}
# Anonymize orders (kept for accounting, PII stripped).
deleted["orders_anonymized"] = await self._orders.anonymize_by_user(user_id)
deleted["activity_logs_deleted"] = await self._activity_logs.delete_by_user(user_id)
await self._users.delete(user_id)
deleted["user_deleted"] = 1
return DeletionReport(
user_id=user_id,
deleted_at=datetime.now(timezone.utc),
items=deleted,
)
## PCI DSS
PCI DSS (Payment Card Industry Data Security Standard) — стандарт безопасности для обработки платёжных карт.
12 требований PCI DSS
#
Требование
Категория
1
Firewall для защиты данных карт
Сеть
2
Не использовать vendor defaults
Конфигурация
3
Защита хранимых данных карт
Хранение
4
Шифрование при передаче
Транспорт
5
Антивирус на всех системах
Защита
6
Безопасная разработка
Разработка
7
Ограничение доступа к данным
Доступ
8
Уникальный ID для каждого пользователя
Идентификация
9
Физическая безопасность
Физика
10
Мониторинг и логирование
Мониторинг
11
Регулярное тестирование
Тестирование
12
Политика безопасности
Управление
Совет: Если возможно, используйте PCI-compliant платёжного провайдера (Stripe, Braintree). Данные карт не проходят через ваш сервер, что значительно снижает scope PCI DSS.
Audit Logging
Audit logging — запись всех значимых действий для обеспечения подотчётности и compliance.
Что логировать
Категория
События
Аутентификация
Login, logout, failed attempts, MFA
Авторизация
Access granted, access denied
Данные
Create, read, update, delete PII
Конфигурация
Settings changes, role changes
Система
Startup, shutdown, errors
Audit Logger
<?php
declare(strict_types=1);
namespace App\Audit;
final readonly class AuditLogger
{
public function __construct(
private AuditRepository $repository,
) {}
/**
* Log an auditable event.
*/
public function log(AuditEvent $event): void
{
$this->repository->store($event);
}
/**
* Log data access (for GDPR compliance).
*/
public function logDataAccess(
string $actorId,
string $dataSubjectId,
string $dataType,
string $action,
string $justification,
): void {
$this->log(new AuditEvent(
eventType: 'data_access',
actorId: $actorId,
resourceType: $dataType,
resourceId: $dataSubjectId,
action: $action,
metadata: [
'justification' => $justification,
'data_classification' => 'restricted',
],
));
}
/**
* Log configuration change.
*/
public function logConfigChange(
string $actorId,
string $setting,
mixed $oldValue,
mixed $newValue,
): void {
$this->log(new AuditEvent(
eventType: 'config_change',
actorId: $actorId,
resourceType: 'configuration',
resourceId: $setting,
action: 'update',
metadata: [
'old_value' => $oldValue,
'new_value' => $newValue,
],
));
}
}
final readonly class AuditEvent
{
public \DateTimeImmutable $timestamp;
public function __construct(
public string $eventType,
public string $actorId,
public string $resourceType,
public string $resourceId,
public string $action,
public array $metadata = [],
) {
$this->timestamp = new \DateTimeImmutable();
}
}
namespace App.Audit;
// An auditable event. Timestamp is assigned at construction time.
public sealed record AuditEvent(
string EventType,
string ActorId,
string ResourceType,
string ResourceId,
string Action,
IReadOnlyDictionary<string, object?>? Metadata = null)
{
public DateTimeOffset Timestamp { get; } = DateTimeOffset.UtcNow;
}
public sealed class AuditLogger(IAuditRepository repository)
{
// Store an auditable event.
public Task LogAsync(AuditEvent auditEvent, CancellationToken ct = default)
=> repository.StoreAsync(auditEvent, ct);
// Record access to personal data (GDPR compliance).
public Task LogDataAccessAsync(
string actorId,
string dataSubjectId,
string dataType,
string action,
string justification,
CancellationToken ct = default)
=> LogAsync(
new AuditEvent(
EventType: "data_access",
ActorId: actorId,
ResourceType: dataType,
ResourceId: dataSubjectId,
Action: action,
Metadata: new Dictionary<string, object?>
{
["justification"] = justification,
["data_classification"] = "restricted",
}),
ct);
// Record a configuration change.
public Task LogConfigChangeAsync(
string actorId,
string setting,
object? oldValue,
object? newValue,
CancellationToken ct = default)
=> LogAsync(
new AuditEvent(
EventType: "config_change",
ActorId: actorId,
ResourceType: "configuration",
ResourceId: setting,
Action: "update",
Metadata: new Dictionary<string, object?>
{
["old_value"] = oldValue,
["new_value"] = newValue,
}),
ct);
}
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
@dataclass(frozen=True, slots=True)
class AuditEvent:
"""An auditable event. Timestamp is assigned at construction time."""
event_type: str
actor_id: str
resource_type: str
resource_id: str
action: str
metadata: dict[str, Any] = field(default_factory=dict)
timestamp: datetime = field(
default_factory=lambda: datetime.now(timezone.utc)
)
class AuditLogger:
def __init__(self, repository: AuditRepository) -> None:
self._repository = repository
async def log(self, event: AuditEvent) -> None:
"""Store an auditable event."""
await self._repository.store(event)
async def log_data_access(
self,
actor_id: str,
data_subject_id: str,
data_type: str,
action: str,
justification: str,
) -> None:
"""Record access to personal data (GDPR compliance)."""
await self.log(
AuditEvent(
event_type="data_access",
actor_id=actor_id,
resource_type=data_type,
resource_id=data_subject_id,
action=action,
metadata={
"justification": justification,
"data_classification": "restricted",
},
)
)
async def log_config_change(
self,
actor_id: str,
setting: str,
old_value: Any,
new_value: Any,
) -> None:
"""Record a configuration change."""
await self.log(
AuditEvent(
event_type="config_change",
actor_id=actor_id,
resource_type="configuration",
resource_id=setting,
action="update",
metadata={"old_value": old_value, "new_value": new_value},
)
)
## Data Retention Policies
Тип данных
Срок хранения
Основание
Транзакции
7 лет
Налоговое законодательство
Логи доступа
1-3 года
PCI DSS, SOC 2
Персональные данные
До отзыва согласия
GDPR
Audit logs
3-7 лет
Compliance
Сессионные данные
30 дней
Операционные нужды
Автоматическая очистка данных
<?php
declare(strict_types=1);
namespace App\Retention;
final readonly class DataRetentionService
{
/**
* Identifiers cannot be parameterized, so allowed targets are whitelisted.
* Interpolating a table or column name straight into SQL is an injection
* point even when every value is bound.
*
* @var array<string, string> Table => timestamp column
*/
private const array RETENTION_TARGETS = [
'session_logs' => 'created_at',
'access_logs' => 'created_at',
];
public function __construct(
private Connection $db,
private AuditLogger $audit,
) {}
/**
* Apply retention policies — delete expired data.
* Run as a daily cron job.
*
* @return array<string, int> Number of deleted records per table
*/
public function applyRetentionPolicies(): array
{
$results = [];
// Delete session logs older than 30 days
$results['session_logs'] = $this->deleteOlderThan('session_logs', 30);
// Delete access logs older than 365 days
$results['access_logs'] = $this->deleteOlderThan('access_logs', 365);
// Anonymize completed orders older than 2 years (keep for stats, remove PII)
$results['orders_anonymized'] = $this->anonymizeOrders(daysOld: 730);
// Log the retention run
$this->audit->log(new AuditEvent(
eventType: 'retention_policy',
actorId: 'system',
resourceType: 'data_retention',
resourceId: 'daily_run',
action: 'delete',
metadata: $results,
));
return $results;
}
private function deleteOlderThan(string $table, int $days): int
{
$column = self::RETENTION_TARGETS[$table]
?? throw new \InvalidArgumentException("Table '{$table}' is not a retention target");
$cutoff = (new \DateTimeImmutable())->modify("-{$days} days")->format('Y-m-d');
return $this->db->executeStatement(
"DELETE FROM {$table} WHERE {$column} < :cutoff",
['cutoff' => $cutoff],
);
}
private function anonymizeOrders(int $daysOld): int
{
$cutoff = (new \DateTimeImmutable())->modify("-{$daysOld} days")->format('Y-m-d');
return $this->db->executeStatement(
"UPDATE orders SET
customer_name = 'ANONYMIZED',
customer_email = NULL,
shipping_address = NULL
WHERE created_at < :cutoff AND customer_name != 'ANONYMIZED'",
['cutoff' => $cutoff],
);
}
}
package retention
import (
"context"
"database/sql"
"fmt"
"time"
)
// retentionTargets whitelists the tables this job may purge.
// Identifiers cannot be parameterized, so allowed targets are whitelisted:
// interpolating a table or column name straight into SQL is an injection
// point even when every value is bound.
var retentionTargets = map[string]string{
"session_logs": "created_at",
"access_logs": "created_at",
}
// Service applies data retention policies.
type Service struct {
db *sql.DB
audit *audit.Logger
}
// ApplyRetentionPolicies deletes expired data. Run as a daily cron job.
func (s *Service) ApplyRetentionPolicies(ctx context.Context) (map[string]int, error) {
results := make(map[string]int)
// Delete session logs older than 30 days
n, err := s.deleteOlderThan(ctx, "session_logs", 30)
if err != nil {
return nil, fmt.Errorf("purge session_logs: %w", err)
}
results["session_logs"] = n
// Delete access logs older than 365 days
n, err = s.deleteOlderThan(ctx, "access_logs", 365)
if err != nil {
return nil, fmt.Errorf("purge access_logs: %w", err)
}
results["access_logs"] = n
// Anonymize completed orders older than 2 years (keep for stats, remove PII)
n, err = s.anonymizeOrders(ctx, 730)
if err != nil {
return nil, fmt.Errorf("anonymize orders: %w", err)
}
results["orders_anonymized"] = n
// The retention run is itself an auditable event.
metadata := make(map[string]any, len(results))
for table, deleted := range results {
metadata[table] = deleted
}
if err := s.audit.Log(audit.Event{
EventType: "retention_policy",
ActorID: "system",
ResourceType: "data_retention",
ResourceID: "daily_run",
Action: "delete",
Metadata: metadata,
}); err != nil {
return nil, fmt.Errorf("audit retention run: %w", err)
}
return results, nil
}
func (s *Service) deleteOlderThan(ctx context.Context, table string, days int) (int, error) {
column, ok := retentionTargets[table]
if !ok {
return 0, fmt.Errorf("table %q is not a retention target", table)
}
cutoff := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
query := fmt.Sprintf("DELETE FROM %s WHERE %s < $1", table, column)
res, err := s.db.ExecContext(ctx, query, cutoff)
if err != nil {
return 0, err
}
n, err := res.RowsAffected()
if err != nil {
return 0, err
}
return int(n), nil
}
func (s *Service) anonymizeOrders(ctx context.Context, daysOld int) (int, error) {
cutoff := time.Now().AddDate(0, 0, -daysOld).Format("2006-01-02")
res, err := s.db.ExecContext(ctx,
`UPDATE orders SET
customer_name = 'ANONYMIZED',
customer_email = NULL,
shipping_address = NULL
WHERE created_at < $1 AND customer_name <> 'ANONYMIZED'`, cutoff)
if err != nil {
return 0, err
}
n, err := res.RowsAffected()
if err != nil {
return 0, err
}
return int(n), nil
}
using Npgsql;
namespace App.Retention;
public sealed class DataRetentionService(
NpgsqlDataSource dataSource,
AuditLogger audit)
{
// Identifiers cannot be parameterized, so allowed targets are whitelisted.
private static readonly Dictionary<string, string> RetentionTargets = new()
{
["session_logs"] = "created_at",
["access_logs"] = "created_at",
};
// Apply retention policies — delete expired data. Run as a daily cron job.
public async Task<IReadOnlyDictionary<string, int>> ApplyRetentionPoliciesAsync(
CancellationToken ct = default)
{
var results = new Dictionary<string, int>
{
["session_logs"] = await DeleteOlderThanAsync("session_logs", 30, ct),
["access_logs"] = await DeleteOlderThanAsync("access_logs", 365, ct),
// Keep orders for stats, strip PII after 2 years.
["orders_anonymized"] = await AnonymizeOrdersAsync(730, ct),
};
await audit.LogAsync(
new AuditEvent(
EventType: "retention_policy",
ActorId: "system",
ResourceType: "data_retention",
ResourceId: "daily_run",
Action: "delete",
Metadata: results.ToDictionary(p => p.Key, p => (object?)p.Value)),
ct);
return results;
}
private async Task<int> DeleteOlderThanAsync(string table, int days, CancellationToken ct)
{
if (!RetentionTargets.TryGetValue(table, out string? column))
{
throw new ArgumentException($"Table '{table}' is not a retention target", nameof(table));
}
DateTime cutoff = DateTime.UtcNow.AddDays(-days).Date;
await using NpgsqlCommand cmd = dataSource.CreateCommand(
$"DELETE FROM {table} WHERE {column} < @cutoff");
cmd.Parameters.AddWithValue("cutoff", cutoff);
return await cmd.ExecuteNonQueryAsync(ct);
}
private async Task<int> AnonymizeOrdersAsync(int daysOld, CancellationToken ct)
{
DateTime cutoff = DateTime.UtcNow.AddDays(-daysOld).Date;
await using NpgsqlCommand cmd = dataSource.CreateCommand(
"""
UPDATE orders SET
customer_name = 'ANONYMIZED',
customer_email = NULL,
shipping_address = NULL
WHERE created_at < @cutoff AND customer_name <> 'ANONYMIZED'
""");
cmd.Parameters.AddWithValue("cutoff", cutoff);
return await cmd.ExecuteNonQueryAsync(ct);
}
}
from datetime import datetime, timedelta, timezone
import psycopg
from psycopg import sql
# Identifiers cannot be parameterized, so allowed targets are whitelisted.
RETENTION_TARGETS: dict[str, str] = {
"session_logs": "created_at",
"access_logs": "created_at",
}
class DataRetentionService:
def __init__(self, conn: psycopg.AsyncConnection, audit: AuditLogger) -> None:
self._conn = conn
self._audit = audit
async def apply_retention_policies(self) -> dict[str, int]:
"""Apply retention policies — delete expired data. Run as a daily cron job."""
results = {
"session_logs": await self._delete_older_than("session_logs", days=30),
"access_logs": await self._delete_older_than("access_logs", days=365),
# Keep orders for stats, strip PII after 2 years.
"orders_anonymized": await self._anonymize_orders(days_old=730),
}
await self._audit.log(
AuditEvent(
event_type="retention_policy",
actor_id="system",
resource_type="data_retention",
resource_id="daily_run",
action="delete",
metadata=dict(results),
)
)
return results
async def _delete_older_than(self, table: str, days: int) -> int:
try:
column = RETENTION_TARGETS[table]
except KeyError as exc:
raise ValueError(f"table {table!r} is not a retention target") from exc
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
query = sql.SQL("DELETE FROM {table} WHERE {column} < %s").format(
table=sql.Identifier(table),
column=sql.Identifier(column),
)
async with self._conn.cursor() as cur:
await cur.execute(query, (cutoff,))
return cur.rowcount
async def _anonymize_orders(self, days_old: int) -> int:
cutoff = datetime.now(timezone.utc) - timedelta(days=days_old)
async with self._conn.cursor() as cur:
await cur.execute(
"""
UPDATE orders SET
customer_name = 'ANONYMIZED',
customer_email = NULL,
shipping_address = NULL
WHERE created_at < %s AND customer_name <> 'ANONYMIZED'
""",
(cutoff,),
)
return cur.rowcount
## Consent Management
<?php
declare(strict_types=1);
namespace App\Privacy;
enum ConsentPurpose: string
{
case Marketing = 'marketing';
case Analytics = 'analytics';
case ThirdPartySharing = 'third_party_sharing';
case Profiling = 'profiling';
case Newsletter = 'newsletter';
}
final readonly class ConsentManager
{
public function __construct(
private Connection $db,
private AuditLogger $audit,
) {}
/**
* Record user consent (GDPR requires proof of consent).
*/
public function grantConsent(
string $userId,
ConsentPurpose $purpose,
string $source,
// Passed in explicitly: reading $_SERVER here would record whatever
// the front controller happened to leave there, including a proxy
// header an attacker controls.
?string $ipAddress = null,
): void {
$this->db->insert('user_consents', [
'user_id' => $userId,
'purpose' => $purpose->value,
'granted' => true,
'source' => $source,
'granted_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'),
'ip_address' => $ipAddress,
]);
$this->audit->logDataAccess($userId, $userId, 'consent', 'grant', $purpose->value);
}
/**
* Check if user has given consent for a specific purpose.
*/
public function hasConsent(string $userId, ConsentPurpose $purpose): bool
{
$result = $this->db->fetchOne(
'SELECT granted FROM user_consents
WHERE user_id = :userId AND purpose = :purpose
ORDER BY granted_at DESC LIMIT 1',
['userId' => $userId, 'purpose' => $purpose->value],
);
return $result === true || $result === 1 || $result === '1';
}
/**
* Revoke consent.
*/
public function revokeConsent(string $userId, ConsentPurpose $purpose): void
{
$this->db->insert('user_consents', [
'user_id' => $userId,
'purpose' => $purpose->value,
'granted' => false,
'source' => 'user_request',
'granted_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'),
]);
$this->audit->logDataAccess($userId, $userId, 'consent', 'revoke', $purpose->value);
}
}
package privacy
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// ConsentPurpose defines the purpose for data processing consent.
type ConsentPurpose string
const (
PurposeMarketing ConsentPurpose = "marketing"
PurposeAnalytics ConsentPurpose = "analytics"
PurposeThirdParty ConsentPurpose = "third_party_sharing"
PurposeProfiling ConsentPurpose = "profiling"
PurposeNewsletter ConsentPurpose = "newsletter"
)
// ConsentManager handles GDPR consent recording and checking.
type ConsentManager struct {
db *sql.DB
audit *audit.Logger
}
// GrantConsent records user consent (GDPR requires proof of consent).
func (m *ConsentManager) GrantConsent(ctx context.Context, userID string, purpose ConsentPurpose, source, ip string) error {
_, err := m.db.ExecContext(ctx,
`INSERT INTO user_consents (user_id, purpose, granted, source, granted_at, ip_address)
VALUES ($1, $2, true, $3, $4, $5)`,
userID, string(purpose), source, time.Now(), ip)
if err != nil {
// Audit only what actually happened: logging a grant that failed to
// persist would leave the trail claiming consent the database lacks.
return fmt.Errorf("record consent: %w", err)
}
if err := m.audit.LogDataAccess(userID, userID, "consent", "grant", string(purpose)); err != nil {
return fmt.Errorf("audit consent grant: %w", err)
}
return nil
}
// HasConsent checks if a user has given consent for a specific purpose.
func (m *ConsentManager) HasConsent(ctx context.Context, userID string, purpose ConsentPurpose) (bool, error) {
var granted bool
err := m.db.QueryRowContext(ctx,
`SELECT granted FROM user_consents
WHERE user_id = $1 AND purpose = $2
ORDER BY granted_at DESC LIMIT 1`,
userID, string(purpose)).Scan(&granted)
// No record at all means consent was never granted.
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("check consent: %w", err)
}
return granted, nil
}
// RevokeConsent records revocation as a new row, preserving the consent history.
func (m *ConsentManager) RevokeConsent(ctx context.Context, userID string, purpose ConsentPurpose) error {
_, err := m.db.ExecContext(ctx,
`INSERT INTO user_consents (user_id, purpose, granted, source, granted_at)
VALUES ($1, $2, false, 'user_request', $3)`,
userID, string(purpose), time.Now())
if err != nil {
return fmt.Errorf("record revocation: %w", err)
}
if err := m.audit.LogDataAccess(userID, userID, "consent", "revoke", string(purpose)); err != nil {
return fmt.Errorf("audit consent revoke: %w", err)
}
return nil
}
using Npgsql;
namespace App.Privacy;
public enum ConsentPurpose
{
Marketing,
Analytics,
ThirdPartySharing,
Profiling,
Newsletter,
}
public static class ConsentPurposeExtensions
{
// Stable storage value, decoupled from the enum member name.
public static string ToStorageValue(this ConsentPurpose purpose) => purpose switch
{
ConsentPurpose.Marketing => "marketing",
ConsentPurpose.Analytics => "analytics",
ConsentPurpose.ThirdPartySharing => "third_party_sharing",
ConsentPurpose.Profiling => "profiling",
ConsentPurpose.Newsletter => "newsletter",
_ => throw new ArgumentOutOfRangeException(nameof(purpose), purpose, "Unknown consent purpose"),
};
}
public sealed class ConsentManager(NpgsqlDataSource dataSource, AuditLogger audit)
{
// Record user consent (GDPR requires proof of consent).
public async Task GrantConsentAsync(
string userId,
ConsentPurpose purpose,
string source,
string? ipAddress,
CancellationToken ct = default)
{
await using NpgsqlCommand cmd = dataSource.CreateCommand(
"""
INSERT INTO user_consents (user_id, purpose, granted, source, granted_at, ip_address)
VALUES (@user_id, @purpose, true, @source, @granted_at, @ip_address)
""");
cmd.Parameters.AddWithValue("user_id", userId);
cmd.Parameters.AddWithValue("purpose", purpose.ToStorageValue());
cmd.Parameters.AddWithValue("source", source);
cmd.Parameters.AddWithValue("granted_at", DateTimeOffset.UtcNow);
cmd.Parameters.AddWithValue("ip_address", (object?)ipAddress ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
await audit.LogDataAccessAsync(
userId, userId, "consent", "grant", purpose.ToStorageValue(), ct);
}
// Check if a user has given consent for a specific purpose.
public async Task<bool> HasConsentAsync(
string userId,
ConsentPurpose purpose,
CancellationToken ct = default)
{
await using NpgsqlCommand cmd = dataSource.CreateCommand(
"""
SELECT granted FROM user_consents
WHERE user_id = @user_id AND purpose = @purpose
ORDER BY granted_at DESC LIMIT 1
""");
cmd.Parameters.AddWithValue("user_id", userId);
cmd.Parameters.AddWithValue("purpose", purpose.ToStorageValue());
object? result = await cmd.ExecuteScalarAsync(ct);
// No record at all means consent was never granted.
return result is bool granted && granted;
}
// Record consent revocation as a new row, preserving the consent history.
public async Task RevokeConsentAsync(
string userId,
ConsentPurpose purpose,
CancellationToken ct = default)
{
await using NpgsqlCommand cmd = dataSource.CreateCommand(
"""
INSERT INTO user_consents (user_id, purpose, granted, source, granted_at)
VALUES (@user_id, @purpose, false, 'user_request', @granted_at)
""");
cmd.Parameters.AddWithValue("user_id", userId);
cmd.Parameters.AddWithValue("purpose", purpose.ToStorageValue());
cmd.Parameters.AddWithValue("granted_at", DateTimeOffset.UtcNow);
await cmd.ExecuteNonQueryAsync(ct);
await audit.LogDataAccessAsync(
userId, userId, "consent", "revoke", purpose.ToStorageValue(), ct);
}
}
from datetime import datetime, timezone
from enum import StrEnum
import psycopg
class ConsentPurpose(StrEnum):
MARKETING = "marketing"
ANALYTICS = "analytics"
THIRD_PARTY_SHARING = "third_party_sharing"
PROFILING = "profiling"
NEWSLETTER = "newsletter"
class ConsentManager:
def __init__(self, conn: psycopg.AsyncConnection, audit: AuditLogger) -> None:
self._conn = conn
self._audit = audit
async def grant_consent(
self,
user_id: str,
purpose: ConsentPurpose,
source: str,
ip_address: str | None = None,
) -> None:
"""Record user consent (GDPR requires proof of consent)."""
async with self._conn.cursor() as cur:
await cur.execute(
"""
INSERT INTO user_consents
(user_id, purpose, granted, source, granted_at, ip_address)
VALUES (%s, %s, true, %s, %s, %s)
""",
(user_id, purpose.value, source, datetime.now(timezone.utc), ip_address),
)
await self._audit.log_data_access(
user_id, user_id, "consent", "grant", purpose.value
)
async def has_consent(self, user_id: str, purpose: ConsentPurpose) -> bool:
"""Check if a user has given consent for a specific purpose."""
async with self._conn.cursor() as cur:
await cur.execute(
"""
SELECT granted FROM user_consents
WHERE user_id = %s AND purpose = %s
ORDER BY granted_at DESC LIMIT 1
""",
(user_id, purpose.value),
)
row = await cur.fetchone()
# No record at all means consent was never granted.
return bool(row[0]) if row is not None else False
async def revoke_consent(self, user_id: str, purpose: ConsentPurpose) -> None:
"""Record revocation as a new row, preserving the consent history."""
async with self._conn.cursor() as cur:
await cur.execute(
"""
INSERT INTO user_consents
(user_id, purpose, granted, source, granted_at)
VALUES (%s, %s, false, 'user_request', %s)
""",
(user_id, purpose.value, datetime.now(timezone.utc)),
)
await self._audit.log_data_access(
user_id, user_id, "consent", "revoke", purpose.value
)