From 5cef6d72e42bfbfea6c2ca112a949426a8b36b96 Mon Sep 17 00:00:00 2001
From: LittleSheep <littlesheep.code@hotmail.com>
Date: Fri, 11 Apr 2025 00:23:55 +0800
Subject: [PATCH] :sparkles: Casbin permission check

---
 DysonNetwork.Sphere/Account/Account.cs        |   2 +
 DysonNetwork.Sphere/Auth/AuthController.cs    |  99 +++--
 DysonNetwork.Sphere/Auth/AuthService.cs       |  96 ++++-
 DysonNetwork.Sphere/Auth/CasbinRequirement.cs |  34 ++
 DysonNetwork.Sphere/Auth/Session.cs           |   2 +-
 DysonNetwork.Sphere/Casbin.conf               |  14 +
 .../DysonNetwork.Sphere.csproj                |   2 +
 ...20250410150812_AddAuthSession.Designer.cs} |  12 +-
 ...on.cs => 20250410150812_AddAuthSession.cs} |   2 +-
 .../20250410161354_AddSuperUser.Designer.cs   | 365 ++++++++++++++++++
 .../Migrations/20250410161354_AddSuperUser.cs |  41 ++
 .../Migrations/AppDatabaseModelSnapshot.cs    |  20 +-
 DysonNetwork.Sphere/Program.cs                |  20 +
 DysonNetwork.Sphere/appsettings.json          |   3 +-
 14 files changed, 640 insertions(+), 72 deletions(-)
 create mode 100644 DysonNetwork.Sphere/Auth/CasbinRequirement.cs
 create mode 100644 DysonNetwork.Sphere/Casbin.conf
 rename DysonNetwork.Sphere/Migrations/{20250409150800_AddAuthSession.Designer.cs => 20250410150812_AddAuthSession.Designer.cs} (98%)
 rename DysonNetwork.Sphere/Migrations/{20250409150800_AddAuthSession.cs => 20250410150812_AddAuthSession.cs} (98%)
 create mode 100644 DysonNetwork.Sphere/Migrations/20250410161354_AddSuperUser.Designer.cs
 create mode 100644 DysonNetwork.Sphere/Migrations/20250410161354_AddSuperUser.cs

diff --git a/DysonNetwork.Sphere/Account/Account.cs b/DysonNetwork.Sphere/Account/Account.cs
index b866aae..ee53561 100644
--- a/DysonNetwork.Sphere/Account/Account.cs
+++ b/DysonNetwork.Sphere/Account/Account.cs
@@ -9,6 +9,8 @@ public class Account : BaseModel
     public long Id { get; set; }
     [MaxLength(256)] public string Name { get; set; } = string.Empty;
     [MaxLength(256)] public string Nick { get; set; } = string.Empty;
+    [MaxLength(32)] public string Language { get; set; } = string.Empty;
+    public bool IsSuperuser { get; set; } = false;
 
     public ICollection<AccountContact> Contacts { get; set; } = new List<AccountContact>();
 
diff --git a/DysonNetwork.Sphere/Auth/AuthController.cs b/DysonNetwork.Sphere/Auth/AuthController.cs
index cf1a7e0..553c588 100644
--- a/DysonNetwork.Sphere/Auth/AuthController.cs
+++ b/DysonNetwork.Sphere/Auth/AuthController.cs
@@ -16,8 +16,8 @@ public class AuthController(AppDatabase db, AccountService accounts, AuthService
     {
         [Required] [MaxLength(256)] public string Account { get; set; } = string.Empty;
         [MaxLength(512)] public string? DeviceId { get; set; }
-        public List<string> Claims { get; set; } = new();
         public List<string> Audiences { get; set; } = new();
+        public List<string> Scopes { get; set; } = new();
     }
 
     [HttpPost("challenge")]
@@ -34,8 +34,8 @@ public class AuthController(AppDatabase db, AccountService accounts, AuthService
             Account = account,
             ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddHours(1)),
             StepTotal = 1,
-            Claims = request.Claims,
             Audiences = request.Audiences,
+            Scopes = request.Scopes,
             IpAddress = ipAddress,
             UserAgent = userAgent,
             DeviceId = request.DeviceId,
@@ -46,6 +46,18 @@ public class AuthController(AppDatabase db, AccountService accounts, AuthService
         return challenge;
     }
 
+    [HttpGet("challenge/{id}/factors")]
+    public async Task<ActionResult<List<AccountAuthFactor>>> GetChallengeFactors([FromRoute] Guid id)
+    {
+        var challenge = await db.AuthChallenges
+            .Include(e => e.Account)
+            .Include(e => e.Account.AuthFactors)
+            .Where(e => e.Id == id).FirstOrDefaultAsync();
+        return challenge is null
+            ? new NotFoundObjectResult("Auth challenge was not found.")
+            : challenge.Account.AuthFactors.ToList();
+    }
+
     public class PerformChallengeRequest
     {
         [Required] public long FactorId { get; set; }
@@ -59,10 +71,10 @@ public class AuthController(AppDatabase db, AccountService accounts, AuthService
     )
     {
         var challenge = await db.AuthChallenges.FindAsync(id);
-        if (challenge is null) return new NotFoundResult();
+        if (challenge is null) return new NotFoundObjectResult("Auth challenge was not found.");
 
         var factor = await db.AccountAuthFactors.FindAsync(request.FactorId);
-        if (factor is null) return new NotFoundResult();
+        if (factor is null) return new NotFoundObjectResult("Auth factor was not found.");
 
         if (challenge.StepRemain == 0) return challenge;
         if (challenge.ExpiredAt.HasValue && challenge.ExpiredAt.Value < Instant.FromDateTimeUtc(DateTime.UtcNow))
@@ -84,67 +96,72 @@ public class AuthController(AppDatabase db, AccountService accounts, AuthService
         await db.SaveChangesAsync();
         return challenge;
     }
-    
-    [HttpPost("challenge/{id}/grant")]
-    public async Task<ActionResult<SignedTokenPair>> GrantChallengeToken([FromRoute] Guid id)
-    {
-        var challenge = await db.AuthChallenges
-            .Include(e => e.Account)
-            .Where(e => e.Id == id)
-            .FirstOrDefaultAsync();
-        if (challenge is null) return new NotFoundResult();
-        if (challenge.StepRemain != 0) return new BadRequestResult();
-        
-        var session = await db.AuthSessions
-            .Where(e => e.Challenge == challenge)
-            .FirstOrDefaultAsync();
-        if (session is not null) return new BadRequestResult();
 
-        session = new Session
-        {
-            LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow),
-            ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)),
-            Account = challenge.Account,
-            Challenge = challenge,
-        };
-        
-        db.AuthSessions.Add(session);
-        await db.SaveChangesAsync();
-        
-        return auth.CreateToken(session);
-    }
-    
     public class TokenExchangeRequest
     {
         public string GrantType { get; set; } = string.Empty;
-        public string RefreshToken { get; set; } = string.Empty;
+        public string? RefreshToken { get; set; }
+        public string? Code { get; set; }
     }
-    
+
     [HttpPost("token")]
     public async Task<ActionResult<SignedTokenPair>> ExchangeToken([FromBody] TokenExchangeRequest request)
     {
+        Session? session;
         switch (request.GrantType)
         {
+            case "authorization_code":
+                var code = Guid.TryParse(request.Code, out var codeId) ? codeId : Guid.Empty;
+                if (code == Guid.Empty)
+                    return new BadRequestObjectResult("Invalid or missing authorization code.");
+                var challenge = await db.AuthChallenges
+                    .Include(e => e.Account)
+                    .Where(e => e.Id == code)
+                    .FirstOrDefaultAsync();
+                if (challenge is null)
+                    return new NotFoundObjectResult("Authorization code not found or expired.");
+                if (challenge.StepRemain != 0)
+                    return new BadRequestObjectResult("Challenge not yet completed.");
+
+                session = await db.AuthSessions
+                    .Where(e => e.Challenge == challenge)
+                    .FirstOrDefaultAsync();
+                if (session is not null)
+                    return new BadRequestObjectResult("Session already exists for this challenge.");
+
+                session = new Session
+                {
+                    LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow),
+                    ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)),
+                    Account = challenge.Account,
+                    Challenge = challenge,
+                };
+
+                db.AuthSessions.Add(session);
+                await db.SaveChangesAsync();
+
+                return auth.CreateToken(session);
             case "refresh_token":
                 var handler = new JwtSecurityTokenHandler();
                 var token = handler.ReadJwtToken(request.RefreshToken);
                 var sessionIdClaim = token.Claims.FirstOrDefault(c => c.Type == "session_id")?.Value;
 
                 if (!Guid.TryParse(sessionIdClaim, out var sessionId))
-                    return new UnauthorizedResult();
+                    return new UnauthorizedObjectResult("Invalid or missing session_id claim in refresh token.");
+
+                session = await db.AuthSessions.FirstOrDefaultAsync(s => s.Id == sessionId);
+                if (session is null)
+                    return new NotFoundObjectResult("Session not found or expired.");
 
-                var session = await db.AuthSessions.FirstOrDefaultAsync(s => s.Id == sessionId);
-                if (session is null) return new NotFoundResult();
-                
                 session.LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow);
                 await db.SaveChangesAsync();
 
                 return auth.CreateToken(session);
             default:
-                return new BadRequestResult();
+                return new BadRequestObjectResult("Unsupported grant type.");
         }
     }
-    
+
     [Authorize]
     [HttpGet("test")]
     public async Task<ActionResult> Test()
diff --git a/DysonNetwork.Sphere/Auth/AuthService.cs b/DysonNetwork.Sphere/Auth/AuthService.cs
index 2a5f921..c7023b3 100644
--- a/DysonNetwork.Sphere/Auth/AuthService.cs
+++ b/DysonNetwork.Sphere/Auth/AuthService.cs
@@ -1,6 +1,8 @@
 using System.IdentityModel.Tokens.Jwt;
 using System.Security.Claims;
 using System.Security.Cryptography;
+using Casbin;
+using Microsoft.AspNetCore.Authorization;
 using Microsoft.IdentityModel.Tokens;
 using NodaTime;
 
@@ -13,8 +15,68 @@ public class SignedTokenPair
     public Instant ExpiredAt { get; set; }
 }
 
-public class AuthService(AppDatabase db, IConfiguration config)
+public class AuthService(AppDatabase db, IConfiguration config, IEnforcer enforcer)
 {
+    public async Task<bool> AssignRoleToUserAsync(string user, string role, string domain = "global")
+    {
+        var added = await enforcer.AddGroupingPolicyAsync(user, role, domain);
+        if (added) await enforcer.SavePolicyAsync();
+        return added;
+    }
+
+    public async Task<bool> AddPermissionToUserAsync(string user, string domain, string obj, string act)
+    {
+        var added = await enforcer.AddPolicyAsync(user, domain, obj, act);
+        if (added) await enforcer.SavePolicyAsync();
+        return added;
+    }
+    
+    public async Task<bool> RemovePermissionFromUserAsync(string user, string domain, string obj, string act)
+    {
+        var removed = await enforcer.RemovePolicyAsync(user, domain, obj, act);
+        if (removed) await enforcer.SavePolicyAsync();
+        return removed;
+    }
+
+    public async Task<bool> CreateRoleAsync(string role, string domain, IEnumerable<(string obj, string act)> permissions)
+    {
+        bool anyAdded = false;
+        foreach (var (obj, act) in permissions)
+        {
+            var added = await enforcer.AddPolicyAsync(role, domain, obj, act);
+            if (added) anyAdded = true;
+        }
+
+        if (anyAdded) await enforcer.SavePolicyAsync();
+        return anyAdded;
+    }
+
+    public async Task<bool> AddPermissionsToRoleAsync(string role, string domain, IEnumerable<(string obj, string act)> permissions)
+    {
+        bool anyAdded = false;
+        foreach (var (obj, act) in permissions)
+        {
+            var added = await enforcer.AddPolicyAsync(role, domain, obj, act);
+            if (added) anyAdded = true;
+        }
+
+        if (anyAdded) await enforcer.SavePolicyAsync();
+        return anyAdded;
+    }
+
+    public async Task<bool> RemovePermissionsFromRoleAsync(string role, string domain, IEnumerable<(string obj, string act)> permissions)
+    {
+        bool anyRemoved = false;
+        foreach (var (obj, act) in permissions)
+        {
+            var removed = await enforcer.RemovePolicyAsync(role, domain, obj, act);
+            if (removed) anyRemoved = true;
+        }
+
+        if (anyRemoved) await enforcer.SavePolicyAsync();
+        return anyRemoved;
+    }
+    
     public SignedTokenPair CreateToken(Session session)
     {
         var privateKeyPem = File.ReadAllText(config["Jwt:PrivateKeyPath"]!);
@@ -23,29 +85,29 @@ public class AuthService(AppDatabase db, IConfiguration config)
         var key = new RsaSecurityKey(rsa);
 
         var creds = new SigningCredentials(key, SecurityAlgorithms.RsaSha256);
+        var claims = new List<Claim>
+        {
+            new("user_id", session.Account.Id.ToString()),
+            new("session_id", session.Id.ToString())
+        };
 
-        var accessTokenClaims = new JwtSecurityToken(
-            issuer: "solar-network",
-            audience: string.Join(',', session.Challenge.Audiences),
-            claims: new List<Claim>
-            {
-                new("user_id", session.Account.Id.ToString()),
-                new("session_id", session.Id.ToString())
-            },
-            expires: DateTime.Now.AddMinutes(30),
-            signingCredentials: creds
-        );
         var refreshTokenClaims = new JwtSecurityToken(
             issuer: "solar-network",
             audience: string.Join(',', session.Challenge.Audiences),
-            claims: new List<Claim>
-            {
-                new("user_id", session.Account.Id.ToString()),
-                new("session_id", session.Id.ToString())
-            },
+            claims: claims,
             expires: DateTime.Now.AddDays(30),
             signingCredentials: creds
         );
+        
+        session.Challenge.Scopes.ForEach(c => claims.Add(new Claim("scope", c)));
+        if(session.Account.IsSuperuser) claims.Add(new Claim("is_superuser", "1"));
+        var accessTokenClaims = new JwtSecurityToken(
+            issuer: "solar-network",
+            audience: string.Join(',', session.Challenge.Audiences),
+            claims: claims,
+            expires: DateTime.Now.AddMinutes(30),
+            signingCredentials: creds
+        );
 
         var handler = new JwtSecurityTokenHandler();
         var accessToken = handler.WriteToken(accessTokenClaims);
diff --git a/DysonNetwork.Sphere/Auth/CasbinRequirement.cs b/DysonNetwork.Sphere/Auth/CasbinRequirement.cs
new file mode 100644
index 0000000..0e26bef
--- /dev/null
+++ b/DysonNetwork.Sphere/Auth/CasbinRequirement.cs
@@ -0,0 +1,34 @@
+using Casbin;
+using Microsoft.AspNetCore.Authorization;
+
+namespace DysonNetwork.Sphere.Auth;
+
+public class CasbinRequirement(string domain, string obj, string act) : IAuthorizationRequirement
+{
+    public string Domain { get; } = domain;
+    public string Object { get; } = obj;
+    public string Action { get; } = act;
+}
+
+public class CasbinAuthorizationHandler(IEnforcer enforcer)
+    : AuthorizationHandler<CasbinRequirement>
+{
+    protected override async Task HandleRequirementAsync(
+        AuthorizationHandlerContext context,
+        CasbinRequirement requirement)
+    {
+        var userId = context.User.FindFirst("user_id")?.Value;
+        if (userId == null) return;
+        var isSuperuser = context.User.FindFirst("is_superuser")?.Value == "1";
+        if (isSuperuser) userId = "super:" + userId;
+
+        var allowed = await enforcer.EnforceAsync(
+            userId,
+            requirement.Domain,
+            requirement.Object,
+            requirement.Action
+        );
+
+        if (allowed) context.Succeed(requirement);
+    }
+}
\ No newline at end of file
diff --git a/DysonNetwork.Sphere/Auth/Session.cs b/DysonNetwork.Sphere/Auth/Session.cs
index 3e06697..9ee4baf 100644
--- a/DysonNetwork.Sphere/Auth/Session.cs
+++ b/DysonNetwork.Sphere/Auth/Session.cs
@@ -22,8 +22,8 @@ public class Challenge : BaseModel
     public int StepRemain { get; set; }
     public int StepTotal { get; set; }
     [Column(TypeName = "jsonb")] public List<long> BlacklistFactors { get; set; } = new();
-    [Column(TypeName = "jsonb")] public List<string> Claims { get; set; } = new();
     [Column(TypeName = "jsonb")] public List<string> Audiences { get; set; } = new();
+    [Column(TypeName = "jsonb")] public List<string> Scopes { get; set; } = new();
     [MaxLength(128)] public string? IpAddress { get; set; }
     [MaxLength(512)] public string? UserAgent { get; set; }
     [MaxLength(256)] public string? DeviceId { get; set; }
diff --git a/DysonNetwork.Sphere/Casbin.conf b/DysonNetwork.Sphere/Casbin.conf
new file mode 100644
index 0000000..b3b09b6
--- /dev/null
+++ b/DysonNetwork.Sphere/Casbin.conf
@@ -0,0 +1,14 @@
+[request_definition]
+r = sub, dom, obj, act
+
+[policy_definition]
+p = sub, dom, obj, act
+
+[role_definition]
+g = _, _, _
+
+[policy_effect]
+e = some(where (p.eft == allow))
+
+[matchers]
+m = r.sub.StartsWith("super:") || (g(r.sub, p.sub, r.dom) && r.dom == p.dom && r.obj == p.obj && r.act == p.act)
\ No newline at end of file
diff --git a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj
index 13e758e..bacff2e 100644
--- a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj
+++ b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj
@@ -10,6 +10,8 @@
 
     <ItemGroup>
         <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
+        <PackageReference Include="Casbin.NET" Version="2.12.0" />
+        <PackageReference Include="Casbin.NET.Adapter.EFCore" Version="2.5.0" />
         <PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
         <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" />
         <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.2" />
diff --git a/DysonNetwork.Sphere/Migrations/20250409150800_AddAuthSession.Designer.cs b/DysonNetwork.Sphere/Migrations/20250410150812_AddAuthSession.Designer.cs
similarity index 98%
rename from DysonNetwork.Sphere/Migrations/20250409150800_AddAuthSession.Designer.cs
rename to DysonNetwork.Sphere/Migrations/20250410150812_AddAuthSession.Designer.cs
index 8a78ce7..2860f65 100644
--- a/DysonNetwork.Sphere/Migrations/20250409150800_AddAuthSession.Designer.cs
+++ b/DysonNetwork.Sphere/Migrations/20250410150812_AddAuthSession.Designer.cs
@@ -14,7 +14,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
 namespace DysonNetwork.Sphere.Migrations
 {
     [DbContext(typeof(AppDatabase))]
-    [Migration("20250409150800_AddAuthSession")]
+    [Migration("20250410150812_AddAuthSession")]
     partial class AddAuthSession
     {
         /// <inheritdoc />
@@ -177,11 +177,6 @@ namespace DysonNetwork.Sphere.Migrations
                         .HasColumnType("jsonb")
                         .HasColumnName("blacklist_factors");
 
-                    b.Property<List<string>>("Claims")
-                        .IsRequired()
-                        .HasColumnType("jsonb")
-                        .HasColumnName("claims");
-
                     b.Property<Instant>("CreatedAt")
                         .HasColumnType("timestamp with time zone")
                         .HasColumnName("created_at");
@@ -209,6 +204,11 @@ namespace DysonNetwork.Sphere.Migrations
                         .HasColumnType("character varying(1024)")
                         .HasColumnName("nonce");
 
+                    b.Property<List<string>>("Scopes")
+                        .IsRequired()
+                        .HasColumnType("jsonb")
+                        .HasColumnName("scopes");
+
                     b.Property<int>("StepRemain")
                         .HasColumnType("integer")
                         .HasColumnName("step_remain");
diff --git a/DysonNetwork.Sphere/Migrations/20250409150800_AddAuthSession.cs b/DysonNetwork.Sphere/Migrations/20250410150812_AddAuthSession.cs
similarity index 98%
rename from DysonNetwork.Sphere/Migrations/20250409150800_AddAuthSession.cs
rename to DysonNetwork.Sphere/Migrations/20250410150812_AddAuthSession.cs
index bcf4a2a..c7bba51 100644
--- a/DysonNetwork.Sphere/Migrations/20250409150800_AddAuthSession.cs
+++ b/DysonNetwork.Sphere/Migrations/20250410150812_AddAuthSession.cs
@@ -22,8 +22,8 @@ namespace DysonNetwork.Sphere.Migrations
                     step_remain = table.Column<int>(type: "integer", nullable: false),
                     step_total = table.Column<int>(type: "integer", nullable: false),
                     blacklist_factors = table.Column<List<long>>(type: "jsonb", nullable: false),
-                    claims = table.Column<List<string>>(type: "jsonb", nullable: false),
                     audiences = table.Column<List<string>>(type: "jsonb", nullable: false),
+                    scopes = table.Column<List<string>>(type: "jsonb", nullable: false),
                     ip_address = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
                     user_agent = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
                     device_id = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
diff --git a/DysonNetwork.Sphere/Migrations/20250410161354_AddSuperUser.Designer.cs b/DysonNetwork.Sphere/Migrations/20250410161354_AddSuperUser.Designer.cs
new file mode 100644
index 0000000..44c0675
--- /dev/null
+++ b/DysonNetwork.Sphere/Migrations/20250410161354_AddSuperUser.Designer.cs
@@ -0,0 +1,365 @@
+// <auto-generated />
+using System;
+using System.Collections.Generic;
+using DysonNetwork.Sphere;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using NodaTime;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace DysonNetwork.Sphere.Migrations
+{
+    [DbContext(typeof(AppDatabase))]
+    [Migration("20250410161354_AddSuperUser")]
+    partial class AddSuperUser
+    {
+        /// <inheritdoc />
+        protected override void BuildTargetModel(ModelBuilder modelBuilder)
+        {
+#pragma warning disable 612, 618
+            modelBuilder
+                .HasAnnotation("ProductVersion", "9.0.3")
+                .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+            NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+            modelBuilder.Entity("DysonNetwork.Sphere.Account.Account", b =>
+                {
+                    b.Property<long>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("bigint")
+                        .HasColumnName("id");
+
+                    NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
+
+                    b.Property<Instant>("CreatedAt")
+                        .HasColumnType("timestamp with time zone")
+                        .HasColumnName("created_at");
+
+                    b.Property<Instant?>("DeletedAt")
+                        .HasColumnType("timestamp with time zone")
+                        .HasColumnName("deleted_at");
+
+                    b.Property<bool>("IsSuperuser")
+                        .HasColumnType("boolean")
+                        .HasColumnName("is_superuser");
+
+                    b.Property<string>("Language")
+                        .IsRequired()
+                        .HasMaxLength(32)
+                        .HasColumnType("character varying(32)")
+                        .HasColumnName("language");
+
+                    b.Property<string>("Name")
+                        .IsRequired()
+                        .HasMaxLength(256)
+                        .HasColumnType("character varying(256)")
+                        .HasColumnName("name");
+
+                    b.Property<string>("Nick")
+                        .IsRequired()
+                        .HasMaxLength(256)
+                        .HasColumnType("character varying(256)")
+                        .HasColumnName("nick");
+
+                    b.Property<Instant>("UpdatedAt")
+                        .HasColumnType("timestamp with time zone")
+                        .HasColumnName("updated_at");
+
+                    b.HasKey("Id")
+                        .HasName("pk_accounts");
+
+                    b.ToTable("accounts", (string)null);
+                });
+
+            modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountAuthFactor", b =>
+                {
+                    b.Property<long>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("bigint")
+                        .HasColumnName("id");
+
+                    NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
+
+                    b.Property<long>("AccountId")
+                        .HasColumnType("bigint")
+                        .HasColumnName("account_id");
+
+                    b.Property<Instant>("CreatedAt")
+                        .HasColumnType("timestamp with time zone")
+                        .HasColumnName("created_at");
+
+                    b.Property<Instant?>("DeletedAt")
+                        .HasColumnType("timestamp with time zone")
+                        .HasColumnName("deleted_at");
+
+                    b.Property<string>("Secret")
+                        .HasColumnType("text")
+                        .HasColumnName("secret");
+
+                    b.Property<int>("Type")
+                        .HasColumnType("integer")
+                        .HasColumnName("type");
+
+                    b.Property<Instant>("UpdatedAt")
+                        .HasColumnType("timestamp with time zone")
+                        .HasColumnName("updated_at");
+
+                    b.HasKey("Id")
+                        .HasName("pk_account_auth_factors");
+
+                    b.HasIndex("AccountId")
+                        .HasDatabaseName("ix_account_auth_factors_account_id");
+
+                    b.ToTable("account_auth_factors", (string)null);
+                });
+
+            modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountContact", b =>
+                {
+                    b.Property<long>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("bigint")
+                        .HasColumnName("id");
+
+                    NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
+
+                    b.Property<long>("AccountId")
+                        .HasColumnType("bigint")
+                        .HasColumnName("account_id");
+
+                    b.Property<string>("Content")
+                        .IsRequired()
+                        .HasMaxLength(1024)
+                        .HasColumnType("character varying(1024)")
+                        .HasColumnName("content");
+
+                    b.Property<Instant>("CreatedAt")
+                        .HasColumnType("timestamp with time zone")
+                        .HasColumnName("created_at");
+
+                    b.Property<Instant?>("DeletedAt")
+                        .HasColumnType("timestamp with time zone")
+                        .HasColumnName("deleted_at");
+
+                    b.Property<int>("Type")
+                        .HasColumnType("integer")
+                        .HasColumnName("type");
+
+                    b.Property<Instant>("UpdatedAt")
+                        .HasColumnType("timestamp with time zone")
+                        .HasColumnName("updated_at");
+
+                    b.Property<Instant?>("VerifiedAt")
+                        .HasColumnType("timestamp with time zone")
+                        .HasColumnName("verified_at");
+
+                    b.HasKey("Id")
+                        .HasName("pk_account_contacts");
+
+                    b.HasIndex("AccountId")
+                        .HasDatabaseName("ix_account_contacts_account_id");
+
+                    b.ToTable("account_contacts", (string)null);
+                });
+
+            modelBuilder.Entity("DysonNetwork.Sphere.Auth.Challenge", b =>
+                {
+                    b.Property<Guid>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("uuid")
+                        .HasColumnName("id");
+
+                    b.Property<long>("AccountId")
+                        .HasColumnType("bigint")
+                        .HasColumnName("account_id");
+
+                    b.Property<List<string>>("Audiences")
+                        .IsRequired()
+                        .HasColumnType("jsonb")
+                        .HasColumnName("audiences");
+
+                    b.Property<List<long>>("BlacklistFactors")
+                        .IsRequired()
+                        .HasColumnType("jsonb")
+                        .HasColumnName("blacklist_factors");
+
+                    b.Property<Instant>("CreatedAt")
+                        .HasColumnType("timestamp with time zone")
+                        .HasColumnName("created_at");
+
+                    b.Property<Instant?>("DeletedAt")
+                        .HasColumnType("timestamp with time zone")
+                        .HasColumnName("deleted_at");
+
+                    b.Property<string>("DeviceId")
+                        .HasMaxLength(256)
+                        .HasColumnType("character varying(256)")
+                        .HasColumnName("device_id");
+
+                    b.Property<Instant?>("ExpiredAt")
+                        .HasColumnType("timestamp with time zone")
+                        .HasColumnName("expired_at");
+
+                    b.Property<string>("IpAddress")
+                        .HasMaxLength(128)
+                        .HasColumnType("character varying(128)")
+                        .HasColumnName("ip_address");
+
+                    b.Property<string>("Nonce")
+                        .HasMaxLength(1024)
+                        .HasColumnType("character varying(1024)")
+                        .HasColumnName("nonce");
+
+                    b.Property<List<string>>("Scopes")
+                        .IsRequired()
+                        .HasColumnType("jsonb")
+                        .HasColumnName("scopes");
+
+                    b.Property<int>("StepRemain")
+                        .HasColumnType("integer")
+                        .HasColumnName("step_remain");
+
+                    b.Property<int>("StepTotal")
+                        .HasColumnType("integer")
+                        .HasColumnName("step_total");
+
+                    b.Property<Instant>("UpdatedAt")
+                        .HasColumnType("timestamp with time zone")
+                        .HasColumnName("updated_at");
+
+                    b.Property<string>("UserAgent")
+                        .HasMaxLength(512)
+                        .HasColumnType("character varying(512)")
+                        .HasColumnName("user_agent");
+
+                    b.HasKey("Id")
+                        .HasName("pk_auth_challenges");
+
+                    b.HasIndex("AccountId")
+                        .HasDatabaseName("ix_auth_challenges_account_id");
+
+                    b.ToTable("auth_challenges", (string)null);
+                });
+
+            modelBuilder.Entity("DysonNetwork.Sphere.Auth.Session", b =>
+                {
+                    b.Property<Guid>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("uuid")
+                        .HasColumnName("id");
+
+                    b.Property<long>("AccountId")
+                        .HasColumnType("bigint")
+                        .HasColumnName("account_id");
+
+                    b.Property<Guid>("ChallengeId")
+                        .HasColumnType("uuid")
+                        .HasColumnName("challenge_id");
+
+                    b.Property<Instant>("CreatedAt")
+                        .HasColumnType("timestamp with time zone")
+                        .HasColumnName("created_at");
+
+                    b.Property<Instant?>("DeletedAt")
+                        .HasColumnType("timestamp with time zone")
+                        .HasColumnName("deleted_at");
+
+                    b.Property<Instant?>("ExpiredAt")
+                        .HasColumnType("timestamp with time zone")
+                        .HasColumnName("expired_at");
+
+                    b.Property<Instant?>("LastGrantedAt")
+                        .HasColumnType("timestamp with time zone")
+                        .HasColumnName("last_granted_at");
+
+                    b.Property<Instant>("UpdatedAt")
+                        .HasColumnType("timestamp with time zone")
+                        .HasColumnName("updated_at");
+
+                    b.HasKey("Id")
+                        .HasName("pk_auth_sessions");
+
+                    b.HasIndex("AccountId")
+                        .HasDatabaseName("ix_auth_sessions_account_id");
+
+                    b.HasIndex("ChallengeId")
+                        .HasDatabaseName("ix_auth_sessions_challenge_id");
+
+                    b.ToTable("auth_sessions", (string)null);
+                });
+
+            modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountAuthFactor", b =>
+                {
+                    b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
+                        .WithMany("AuthFactors")
+                        .HasForeignKey("AccountId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired()
+                        .HasConstraintName("fk_account_auth_factors_accounts_account_id");
+
+                    b.Navigation("Account");
+                });
+
+            modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountContact", b =>
+                {
+                    b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
+                        .WithMany("Contacts")
+                        .HasForeignKey("AccountId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired()
+                        .HasConstraintName("fk_account_contacts_accounts_account_id");
+
+                    b.Navigation("Account");
+                });
+
+            modelBuilder.Entity("DysonNetwork.Sphere.Auth.Challenge", b =>
+                {
+                    b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
+                        .WithMany("Challenges")
+                        .HasForeignKey("AccountId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired()
+                        .HasConstraintName("fk_auth_challenges_accounts_account_id");
+
+                    b.Navigation("Account");
+                });
+
+            modelBuilder.Entity("DysonNetwork.Sphere.Auth.Session", b =>
+                {
+                    b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
+                        .WithMany("Sessions")
+                        .HasForeignKey("AccountId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired()
+                        .HasConstraintName("fk_auth_sessions_accounts_account_id");
+
+                    b.HasOne("DysonNetwork.Sphere.Auth.Challenge", "Challenge")
+                        .WithMany()
+                        .HasForeignKey("ChallengeId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired()
+                        .HasConstraintName("fk_auth_sessions_auth_challenges_challenge_id");
+
+                    b.Navigation("Account");
+
+                    b.Navigation("Challenge");
+                });
+
+            modelBuilder.Entity("DysonNetwork.Sphere.Account.Account", b =>
+                {
+                    b.Navigation("AuthFactors");
+
+                    b.Navigation("Challenges");
+
+                    b.Navigation("Contacts");
+
+                    b.Navigation("Sessions");
+                });
+#pragma warning restore 612, 618
+        }
+    }
+}
diff --git a/DysonNetwork.Sphere/Migrations/20250410161354_AddSuperUser.cs b/DysonNetwork.Sphere/Migrations/20250410161354_AddSuperUser.cs
new file mode 100644
index 0000000..11db0f8
--- /dev/null
+++ b/DysonNetwork.Sphere/Migrations/20250410161354_AddSuperUser.cs
@@ -0,0 +1,41 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace DysonNetwork.Sphere.Migrations
+{
+    /// <inheritdoc />
+    public partial class AddSuperUser : Migration
+    {
+        /// <inheritdoc />
+        protected override void Up(MigrationBuilder migrationBuilder)
+        {
+            migrationBuilder.AddColumn<bool>(
+                name: "is_superuser",
+                table: "accounts",
+                type: "boolean",
+                nullable: false,
+                defaultValue: false);
+
+            migrationBuilder.AddColumn<string>(
+                name: "language",
+                table: "accounts",
+                type: "character varying(32)",
+                maxLength: 32,
+                nullable: false,
+                defaultValue: "");
+        }
+
+        /// <inheritdoc />
+        protected override void Down(MigrationBuilder migrationBuilder)
+        {
+            migrationBuilder.DropColumn(
+                name: "is_superuser",
+                table: "accounts");
+
+            migrationBuilder.DropColumn(
+                name: "language",
+                table: "accounts");
+        }
+    }
+}
diff --git a/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs b/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs
index 21df139..3f6c0fa 100644
--- a/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs
+++ b/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs
@@ -41,6 +41,16 @@ namespace DysonNetwork.Sphere.Migrations
                         .HasColumnType("timestamp with time zone")
                         .HasColumnName("deleted_at");
 
+                    b.Property<bool>("IsSuperuser")
+                        .HasColumnType("boolean")
+                        .HasColumnName("is_superuser");
+
+                    b.Property<string>("Language")
+                        .IsRequired()
+                        .HasMaxLength(32)
+                        .HasColumnType("character varying(32)")
+                        .HasColumnName("language");
+
                     b.Property<string>("Name")
                         .IsRequired()
                         .HasMaxLength(256)
@@ -174,11 +184,6 @@ namespace DysonNetwork.Sphere.Migrations
                         .HasColumnType("jsonb")
                         .HasColumnName("blacklist_factors");
 
-                    b.Property<List<string>>("Claims")
-                        .IsRequired()
-                        .HasColumnType("jsonb")
-                        .HasColumnName("claims");
-
                     b.Property<Instant>("CreatedAt")
                         .HasColumnType("timestamp with time zone")
                         .HasColumnName("created_at");
@@ -206,6 +211,11 @@ namespace DysonNetwork.Sphere.Migrations
                         .HasColumnType("character varying(1024)")
                         .HasColumnName("nonce");
 
+                    b.Property<List<string>>("Scopes")
+                        .IsRequired()
+                        .HasColumnType("jsonb")
+                        .HasColumnName("scopes");
+
                     b.Property<int>("StepRemain")
                         .HasColumnType("integer")
                         .HasColumnName("step_remain");
diff --git a/DysonNetwork.Sphere/Program.cs b/DysonNetwork.Sphere/Program.cs
index db654a9..f5ebea5 100644
--- a/DysonNetwork.Sphere/Program.cs
+++ b/DysonNetwork.Sphere/Program.cs
@@ -1,9 +1,12 @@
 using System.Security.Cryptography;
 using System.Text;
 using System.Text.Json;
+using Casbin;
+using Casbin.Persist.Adapter.EFCore;
 using DysonNetwork.Sphere;
 using DysonNetwork.Sphere.Account;
 using DysonNetwork.Sphere.Auth;
+using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.HttpOverrides;
 using Microsoft.EntityFrameworkCore;
 using Microsoft.IdentityModel.Tokens;
@@ -25,6 +28,23 @@ builder.Services.AddControllers().AddJsonOptions(options =>
 });
 builder.Services.AddHttpContextAccessor();
 
+// Casbin permissions
+
+var casbinDbContext = new CasbinDbContext<int>(
+    new DbContextOptionsBuilder<CasbinDbContext<int>>()
+        .UseNpgsql(builder.Configuration.GetConnectionString("Guard"))
+        .Options
+);
+var casbinEfcore = new EFCoreAdapter<int>(casbinDbContext);
+casbinDbContext.Database.EnsureCreated();
+var casbinEncofcer = new Enforcer("Casbin.conf", casbinEfcore);
+casbinEncofcer.LoadPolicy();
+
+builder.Services.AddSingleton<IEnforcer>(casbinEncofcer);
+builder.Services.AddSingleton<IAuthorizationHandler, CasbinAuthorizationHandler>();
+
+// Other pipelines
+
 builder.Services.AddAuthorization();
 builder.Services.AddAuthentication("Bearer").AddJwtBearer(options =>
 {
diff --git a/DysonNetwork.Sphere/appsettings.json b/DysonNetwork.Sphere/appsettings.json
index b6cf0b0..307b405 100644
--- a/DysonNetwork.Sphere/appsettings.json
+++ b/DysonNetwork.Sphere/appsettings.json
@@ -7,7 +7,8 @@
   },
   "AllowedHosts": "*",
   "ConnectionStrings": {
-    "App": "Host=localhost;Port=5432;Database=dyson_network;Username=postgres;Password=postgres"
+    "App": "Host=localhost;Port=5432;Database=dyson_network;Username=postgres;Password=postgres",
+    "Guard": "Host=localhost;Port=5432;Database=dyson_network_casbin;Username=postgres;Password=postgres"
   },
   "Authentication": {
     "Schemes": {