diff --git a/DysonNetwork.Pass/PermissionController.cs b/DysonNetwork.Pass/PermissionController.cs
new file mode 100644
index 0000000..c10d9b4
--- /dev/null
+++ b/DysonNetwork.Pass/PermissionController.cs
@@ -0,0 +1,345 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using DysonNetwork.Pass.Permission;
+using DysonNetwork.Shared.Models;
+using NodaTime;
+using System.Text.Json;
+
+namespace DysonNetwork.Pass;
+
+[ApiController]
+[Route("/api/permissions")]
+[Authorize]
+public class PermissionController(
+ PermissionService permissionService,
+ AppDatabase db
+) : ControllerBase
+{
+ ///
+ /// Check if an actor has a specific permission
+ ///
+ [HttpGet("check/{actor}/{area}/{key}")]
+ [RequiredPermission("maintenance", "permissions.check")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ public async Task CheckPermission(string actor, string area, string key)
+ {
+ try
+ {
+ var hasPermission = await permissionService.HasPermissionAsync(actor, area, key);
+ return Ok(hasPermission);
+ }
+ catch (ArgumentException ex)
+ {
+ return BadRequest(new { error = ex.Message });
+ }
+ catch (Exception ex)
+ {
+ return StatusCode(500, new { error = "Failed to check permission", details = ex.Message });
+ }
+ }
+
+ ///
+ /// Get all effective permissions for an actor (including group permissions)
+ ///
+ [HttpGet("actors/{actor}/permissions/effective")]
+ [RequiredPermission("maintenance", "permissions.check")]
+ [ProducesResponseType>(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ public async Task GetEffectivePermissions(string actor)
+ {
+ try
+ {
+ var permissions = await permissionService.ListEffectivePermissionsAsync(actor);
+ return Ok(permissions);
+ }
+ catch (ArgumentException ex)
+ {
+ return BadRequest(new { error = ex.Message });
+ }
+ catch (Exception ex)
+ {
+ return StatusCode(500, new { error = "Failed to list permissions", details = ex.Message });
+ }
+ }
+
+ ///
+ /// Get all direct permissions for an actor (excluding group permissions)
+ ///
+ [HttpGet("actors/{actor}/permissions/direct")]
+ [RequiredPermission("maintenance", "permissions.check")]
+ [ProducesResponseType>(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ public async Task GetDirectPermissions(string actor)
+ {
+ try
+ {
+ var permissions = await permissionService.ListDirectPermissionsAsync(actor);
+ return Ok(permissions);
+ }
+ catch (ArgumentException ex)
+ {
+ return BadRequest(new { error = ex.Message });
+ }
+ catch (Exception ex)
+ {
+ return StatusCode(500, new { error = "Failed to list permissions", details = ex.Message });
+ }
+ }
+
+ ///
+ /// Give a permission to an actor
+ ///
+ [HttpPost("actors/{actor}/permissions/{area}/{key}")]
+ [RequiredPermission("maintenance", "permissions.manage")]
+ [ProducesResponseType(StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ public async Task GivePermission(
+ string actor,
+ string area,
+ string key,
+ [FromBody] PermissionRequest request)
+ {
+ try
+ {
+ var permission = await permissionService.AddPermissionNode(
+ actor,
+ area,
+ key,
+ JsonDocument.Parse(JsonSerializer.Serialize(request.Value)),
+ request.ExpiredAt,
+ request.AffectedAt
+ );
+ return Created($"/api/permissions/actors/{actor}/permissions/{area}/{key}", permission);
+ }
+ catch (ArgumentException ex)
+ {
+ return BadRequest(new { error = ex.Message });
+ }
+ catch (Exception ex)
+ {
+ return StatusCode(500, new { error = "Failed to add permission", details = ex.Message });
+ }
+ }
+
+ ///
+ /// Remove a permission from an actor
+ ///
+ [HttpDelete("actors/{actor}/permissions/{area}/{key}")]
+ [RequiredPermission("maintenance", "permissions.manage")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ public async Task RemovePermission(string actor, string area, string key)
+ {
+ try
+ {
+ await permissionService.RemovePermissionNode(actor, area, key);
+ return NoContent();
+ }
+ catch (ArgumentException ex)
+ {
+ return BadRequest(new { error = ex.Message });
+ }
+ catch (Exception ex)
+ {
+ return StatusCode(500, new { error = "Failed to remove permission", details = ex.Message });
+ }
+ }
+
+ ///
+ /// Get all groups for an actor
+ ///
+ [HttpGet("actors/{actor}/groups")]
+ [RequiredPermission("maintenance", "permissions.groups.check")]
+ [ProducesResponseType>(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ public async Task GetActorGroups(string actor)
+ {
+ try
+ {
+ var now = SystemClock.Instance.GetCurrentInstant();
+ var groups = await db.PermissionGroupMembers
+ .Where(m => m.Actor == actor)
+ .Where(m => m.ExpiredAt == null || m.ExpiredAt > now)
+ .Where(m => m.AffectedAt == null || m.AffectedAt <= now)
+ .Include(m => m.Group)
+ .ToListAsync();
+
+ return Ok(groups);
+ }
+ catch (Exception ex)
+ {
+ return StatusCode(500, new { error = "Failed to list actor groups", details = ex.Message });
+ }
+ }
+
+ ///
+ /// Add an actor to a permission group
+ ///
+ [HttpPost("actors/{actor}/groups/{groupId}")]
+ [RequiredPermission("maintenance", "permissions.groups.manage")]
+ [ProducesResponseType(StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ public async Task AddActorToGroup(
+ string actor,
+ Guid groupId,
+ [FromBody] GroupMembershipRequest? request = null)
+ {
+ try
+ {
+ var group = await db.PermissionGroups.FindAsync(groupId);
+ if (group == null)
+ {
+ return NotFound(new { error = "Permission group not found" });
+ }
+
+ // Check if actor is already in the group
+ var existing = await db.PermissionGroupMembers
+ .FirstOrDefaultAsync(m => m.Actor == actor && m.GroupId == groupId);
+
+ if (existing != null)
+ {
+ return BadRequest(new { error = "Actor is already in this group" });
+ }
+
+ var member = new SnPermissionGroupMember
+ {
+ Actor = actor,
+ GroupId = groupId,
+ Group = group,
+ ExpiredAt = request?.ExpiredAt,
+ AffectedAt = request?.AffectedAt
+ };
+
+ db.PermissionGroupMembers.Add(member);
+ await db.SaveChangesAsync();
+
+ // Clear actor cache
+ await permissionService.ClearActorCacheAsync(actor);
+
+ return Created($"/api/permissions/actors/{actor}/groups/{groupId}", member);
+ }
+ catch (Exception ex)
+ {
+ return StatusCode(500, new { error = "Failed to add actor to group", details = ex.Message });
+ }
+ }
+
+ ///
+ /// Remove an actor from a permission group
+ ///
+ [HttpDelete("actors/{actor}/groups/{groupId}")]
+ [RequiredPermission("maintenance", "permissions.groups.manage")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ public async Task RemoveActorFromGroup(string actor, Guid groupId)
+ {
+ try
+ {
+ var member = await db.PermissionGroupMembers
+ .FirstOrDefaultAsync(m => m.Actor == actor && m.GroupId == groupId);
+
+ if (member == null)
+ {
+ return NotFound(new { error = "Actor is not in this group" });
+ }
+
+ db.PermissionGroupMembers.Remove(member);
+ await db.SaveChangesAsync();
+
+ // Clear actor cache
+ await permissionService.ClearActorCacheAsync(actor);
+
+ return NoContent();
+ }
+ catch (Exception ex)
+ {
+ return StatusCode(500, new { error = "Failed to remove actor from group", details = ex.Message });
+ }
+ }
+
+ ///
+ /// Clear permission cache for an actor
+ ///
+ [HttpPost("actors/{actor}/cache/clear")]
+ [RequiredPermission("maintenance", "permissions.cache.manage")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ public async Task ClearActorCache(string actor)
+ {
+ try
+ {
+ await permissionService.ClearActorCacheAsync(actor);
+ return NoContent();
+ }
+ catch (ArgumentException ex)
+ {
+ return BadRequest(new { error = ex.Message });
+ }
+ catch (Exception ex)
+ {
+ return StatusCode(500, new { error = "Failed to clear cache", details = ex.Message });
+ }
+ }
+
+ ///
+ /// Validate a permission pattern
+ ///
+ [HttpPost("validate-pattern")]
+ [RequiredPermission("maintenance", "permissions.check")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ public IActionResult ValidatePattern([FromBody] PatternValidationRequest request)
+ {
+ try
+ {
+ var isValid = PermissionService.IsValidPermissionPattern(request.Pattern);
+ return Ok(new PatternValidationResponse
+ {
+ Pattern = request.Pattern,
+ IsValid = isValid,
+ Message = isValid ? "Pattern is valid" : "Pattern contains invalid characters or consecutive wildcards"
+ });
+ }
+ catch (Exception ex)
+ {
+ return BadRequest(new { error = ex.Message });
+ }
+ }
+}
+
+public class PermissionRequest
+{
+ public object? Value { get; set; }
+ public NodaTime.Instant? ExpiredAt { get; set; }
+ public NodaTime.Instant? AffectedAt { get; set; }
+}
+
+public class GroupMembershipRequest
+{
+ public NodaTime.Instant? ExpiredAt { get; set; }
+ public NodaTime.Instant? AffectedAt { get; set; }
+}
+
+public class PatternValidationRequest
+{
+ public string Pattern { get; set; } = string.Empty;
+}
+
+public class PatternValidationResponse
+{
+ public string Pattern { get; set; } = string.Empty;
+ public bool IsValid { get; set; }
+ public string Message { get; set; } = string.Empty;
+}