forked from baron/baron-sso
RP 관계 범위의 콘솔 접근 허용
This commit is contained in:
@@ -156,11 +156,26 @@ func (m *mockConsentRepo) ListBySubject(ctx context.Context, subject string) ([]
|
|||||||
}
|
}
|
||||||
func (m *mockConsentRepo) Delete(ctx context.Context, clientID, subject string) error { return nil }
|
func (m *mockConsentRepo) Delete(ctx context.Context, clientID, subject string) error { return nil }
|
||||||
func (m *mockConsentRepo) List(ctx context.Context, clientID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error) {
|
func (m *mockConsentRepo) List(ctx context.Context, clientID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error) {
|
||||||
return nil, 0, nil
|
results := make([]domain.ClientConsentWithTenantInfo, 0, len(m.consents))
|
||||||
|
for _, consent := range m.consents {
|
||||||
|
if consent.ClientID == clientID {
|
||||||
|
results = append(results, domain.ClientConsentWithTenantInfo{ClientConsent: consent})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results, int64(len(results)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockConsentRepo) ListByTenant(ctx context.Context, clientID, tenantID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error) {
|
func (m *mockConsentRepo) ListByTenant(ctx context.Context, clientID, tenantID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error) {
|
||||||
return nil, 0, nil
|
results := make([]domain.ClientConsentWithTenantInfo, 0, len(m.consents))
|
||||||
|
for _, consent := range m.consents {
|
||||||
|
if consent.ClientID == clientID {
|
||||||
|
results = append(results, domain.ClientConsentWithTenantInfo{
|
||||||
|
ClientConsent: consent,
|
||||||
|
TenantID: tenantID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results, int64(len(results)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Mock Secret Repository ---
|
// --- Mock Secret Repository ---
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ var allowedRelyingPartyOperatorRelations = map[string]struct{}{
|
|||||||
"consent_viewer": {},
|
"consent_viewer": {},
|
||||||
"consent_revoker": {},
|
"consent_revoker": {},
|
||||||
"relationship_viewer": {},
|
"relationship_viewer": {},
|
||||||
|
"audit_viewer": {},
|
||||||
"status_operator": {},
|
"status_operator": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,6 +222,15 @@ func isDevConsoleRoleAllowed(role string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isDevConsoleViewerRole(role string) bool {
|
||||||
|
switch normalizeUserRole(role) {
|
||||||
|
case domain.RoleSuperAdmin, domain.RoleTenantAdmin, domain.RoleRPAdmin, domain.RoleUser:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *DevHandler) getCurrentProfile(c *fiber.Ctx) *domain.UserProfileResponse {
|
func (h *DevHandler) getCurrentProfile(c *fiber.Ctx) *domain.UserProfileResponse {
|
||||||
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
|
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
|
||||||
return profile
|
return profile
|
||||||
@@ -388,6 +398,51 @@ func (h *DevHandler) canManageClientRelations(c *fiber.Ctx, profile *domain.User
|
|||||||
return canAccessClientByLegacyScope(profile, summary)
|
return canAccessClientByLegacyScope(profile, summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *DevHandler) auditClientIDsByPermit(c *fiber.Ctx, profile *domain.UserProfileResponse, clientFilter string) map[string]struct{} {
|
||||||
|
ids := make(map[string]struct{})
|
||||||
|
if profile == nil || h.Hydra == nil {
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
if normalizeUserRole(profile.Role) == domain.RoleSuperAdmin {
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
clientFilter = strings.TrimSpace(clientFilter)
|
||||||
|
if clientFilter != "" {
|
||||||
|
summary, err := h.loadClientSummary(c.Context(), clientFilter)
|
||||||
|
if err == nil && h.canOperateClientByPermit(c, profile, summary, "view_audit_logs") {
|
||||||
|
ids[summary.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
clients, err := h.Hydra.ListClients(c.Context(), 500, 0)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("Failed to list clients for audit permission filtering", "error", err)
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
for _, client := range clients {
|
||||||
|
if isHiddenSystemClient(client) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
summary := h.mapClientSummary(client)
|
||||||
|
if h.canOperateClientByPermit(c, profile, summary, "view_audit_logs") {
|
||||||
|
ids[summary.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeStringSets(dst map[string]struct{}, src map[string]struct{}) map[string]struct{} {
|
||||||
|
if dst == nil {
|
||||||
|
dst = make(map[string]struct{}, len(src))
|
||||||
|
}
|
||||||
|
for key := range src {
|
||||||
|
dst[key] = struct{}{}
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
func canAccessClientByLegacyScope(profile *domain.UserProfileResponse, summary clientSummary) bool {
|
func canAccessClientByLegacyScope(profile *domain.UserProfileResponse, summary clientSummary) bool {
|
||||||
if profile == nil {
|
if profile == nil {
|
||||||
return false
|
return false
|
||||||
@@ -938,7 +993,7 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
|||||||
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
|
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
|
||||||
}
|
}
|
||||||
role := normalizeUserRole(profile.Role)
|
role := normalizeUserRole(profile.Role)
|
||||||
if !isDevConsoleRoleAllowed(role) {
|
if !isDevConsoleViewerRole(role) {
|
||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -972,14 +1027,16 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
|||||||
summary := h.mapClientSummary(client)
|
summary := h.mapClientSummary(client)
|
||||||
|
|
||||||
// 1. [Security] Filter out 'private' clients if user is not an AppManager
|
// 1. [Security] Filter out 'private' clients if user is not an AppManager
|
||||||
if summary.Type == "private" && !isAppManager {
|
canViewByPermit := h.canViewClientByPermit(c, profile, summary)
|
||||||
|
|
||||||
|
if summary.Type == "private" && !isAppManager && !canViewByPermit {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. [Isolation] If not SuperAdmin, only show clients belonging to the same tenant
|
// 2. [Isolation] If not SuperAdmin, only show clients belonging to the same tenant
|
||||||
if !isSuperAdmin {
|
if !isSuperAdmin {
|
||||||
clientTenantID, _ := summary.Metadata["tenant_id"].(string)
|
clientTenantID, _ := summary.Metadata["tenant_id"].(string)
|
||||||
if clientTenantID != userTenantID && !h.canViewClientByPermit(c, profile, summary) {
|
if clientTenantID != userTenantID && !canViewByPermit {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -987,13 +1044,13 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
|||||||
// 3. [Role Scope] RP Admin can only access managed RP IDs unless explicit Keto permit exists
|
// 3. [Role Scope] RP Admin can only access managed RP IDs unless explicit Keto permit exists
|
||||||
if role == domain.RoleRPAdmin && len(allowedClientIDs) > 0 {
|
if role == domain.RoleRPAdmin && len(allowedClientIDs) > 0 {
|
||||||
if _, ok := allowedClientIDs[summary.ID]; !ok {
|
if _, ok := allowedClientIDs[summary.ID]; !ok {
|
||||||
if !h.canViewClientByPermit(c, profile, summary) {
|
if !canViewByPermit {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isSuperAdmin && !canAccessClientByLegacyScope(profile, summary) && !h.canViewClientByPermit(c, profile, summary) {
|
if !isSuperAdmin && !canAccessClientByLegacyScope(profile, summary) && !canViewByPermit {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1163,7 +1220,7 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error {
|
|||||||
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
|
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
|
||||||
}
|
}
|
||||||
role := normalizeUserRole(profile.Role)
|
role := normalizeUserRole(profile.Role)
|
||||||
if !isDevConsoleRoleAllowed(role) {
|
if !isDevConsoleViewerRole(role) {
|
||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1172,7 +1229,7 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check permission for private clients
|
// Check permission for private clients
|
||||||
if summary.Type == "private" {
|
if summary.Type == "private" && !h.canViewClientByPermit(c, profile, summary) {
|
||||||
isAppManager, err := h.checkAppManagerPermission(c)
|
isAppManager, err := h.checkAppManagerPermission(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, "permission check error")
|
return errorJSON(c, fiber.StatusInternalServerError, "permission check error")
|
||||||
@@ -1730,7 +1787,7 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
|||||||
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
|
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
|
||||||
}
|
}
|
||||||
role := normalizeUserRole(profile.Role)
|
role := normalizeUserRole(profile.Role)
|
||||||
if !isDevConsoleRoleAllowed(role) {
|
if !isDevConsoleViewerRole(role) {
|
||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
||||||
}
|
}
|
||||||
client, err := h.Hydra.GetClient(c.Context(), clientID)
|
client, err := h.Hydra.GetClient(c.Context(), clientID)
|
||||||
@@ -1741,7 +1798,8 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
|||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
summary := h.mapClientSummary(*client)
|
summary := h.mapClientSummary(*client)
|
||||||
if !canAccessClientByLegacyScope(profile, summary) && !h.canOperateClientByPermit(c, profile, summary, "view_consents") {
|
canViewConsentsByPermit := h.canOperateClientByPermit(c, profile, summary, "view_consents")
|
||||||
|
if !canAccessClientByLegacyScope(profile, summary) && !canViewConsentsByPermit {
|
||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client")
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1755,7 +1813,7 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
|||||||
// [Isolation] Get admin tenant ID from locals or header
|
// [Isolation] Get admin tenant ID from locals or header
|
||||||
adminTenantID := ""
|
adminTenantID := ""
|
||||||
if profile != nil {
|
if profile != nil {
|
||||||
if role != domain.RoleSuperAdmin && profile.TenantID != nil {
|
if role != domain.RoleSuperAdmin && !canViewConsentsByPermit && profile.TenantID != nil {
|
||||||
adminTenantID = *profile.TenantID
|
adminTenantID = *profile.TenantID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1813,12 +1871,14 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
userName := ""
|
userName := ""
|
||||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), consent.Subject)
|
if h.KratosAdmin != nil {
|
||||||
if err == nil && identity != nil {
|
identity, err := h.KratosAdmin.GetIdentity(c.Context(), consent.Subject)
|
||||||
if name, ok := identity.Traits["name"].(string); ok {
|
if err == nil && identity != nil {
|
||||||
userName = name
|
if name, ok := identity.Traits["name"].(string); ok {
|
||||||
} else if email, ok := identity.Traits["email"].(string); ok {
|
userName = name
|
||||||
userName = email
|
} else if email, ok := identity.Traits["email"].(string); ok {
|
||||||
|
userName = email
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1854,7 +1914,7 @@ func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error {
|
|||||||
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
|
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
|
||||||
}
|
}
|
||||||
role := normalizeUserRole(profile.Role)
|
role := normalizeUserRole(profile.Role)
|
||||||
if !isDevConsoleRoleAllowed(role) {
|
if !isDevConsoleViewerRole(role) {
|
||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
||||||
}
|
}
|
||||||
if clientID != "" {
|
if clientID != "" {
|
||||||
@@ -2123,13 +2183,9 @@ func (h *DevHandler) ListAuditLogs(c *fiber.Ctx) error {
|
|||||||
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
|
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
|
||||||
}
|
}
|
||||||
role := normalizeUserRole(profile.Role)
|
role := normalizeUserRole(profile.Role)
|
||||||
if !isDevConsoleRoleAllowed(role) {
|
if !isDevConsoleViewerRole(role) {
|
||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
||||||
}
|
}
|
||||||
allowedClientIDs := managedClientIDsFromProfile(profile)
|
|
||||||
if role == domain.RoleRPAdmin && len(allowedClientIDs) == 0 {
|
|
||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin has no managed clients")
|
|
||||||
}
|
|
||||||
|
|
||||||
limit := c.QueryInt("limit", 50)
|
limit := c.QueryInt("limit", 50)
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
@@ -2142,11 +2198,21 @@ func (h *DevHandler) ListAuditLogs(c *fiber.Ctx) error {
|
|||||||
actionFilter := strings.ToUpper(strings.TrimSpace(c.Query("action")))
|
actionFilter := strings.ToUpper(strings.TrimSpace(c.Query("action")))
|
||||||
clientFilter := strings.TrimSpace(c.Query("client_id"))
|
clientFilter := strings.TrimSpace(c.Query("client_id"))
|
||||||
statusFilter := strings.ToLower(strings.TrimSpace(c.Query("status")))
|
statusFilter := strings.ToLower(strings.TrimSpace(c.Query("status")))
|
||||||
|
allowedClientIDs := managedClientIDsFromProfile(profile)
|
||||||
|
allowedClientIDs = mergeStringSets(allowedClientIDs, h.auditClientIDsByPermit(c, profile, clientFilter))
|
||||||
|
if role != domain.RoleSuperAdmin && len(allowedClientIDs) == 0 && (role == domain.RoleRPAdmin || role == domain.RoleUser) {
|
||||||
|
return c.JSON(devAuditListResponse{
|
||||||
|
Items: []domain.AuditLog{},
|
||||||
|
Limit: limit,
|
||||||
|
Cursor: c.Query("cursor"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
tenantFilter := strings.TrimSpace(c.Query("tenant_id"))
|
tenantFilter := strings.TrimSpace(c.Query("tenant_id"))
|
||||||
if tenantFilter == "" {
|
if tenantFilter == "" {
|
||||||
tenantFilter = h.resolveDevTenantScope(c)
|
tenantFilter = h.resolveDevTenantScope(c)
|
||||||
}
|
}
|
||||||
if role != domain.RoleSuperAdmin && tenantFilter == "" {
|
if role != domain.RoleSuperAdmin && tenantFilter == "" && len(allowedClientIDs) == 0 {
|
||||||
tenantFilter = tenantIDFromProfile(profile)
|
tenantFilter = tenantIDFromProfile(profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -229,6 +229,54 @@ func TestListClients_Success(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListClients_UserSeesOnlyClientsAllowedByReBAC(t *testing.T) {
|
||||||
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
if r.URL.Path == "/clients" {
|
||||||
|
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
||||||
|
{"client_id": "client-denied", "client_name": "Denied App", "metadata": map[string]interface{}{"tenant_id": "tenant-a", "status": "active"}},
|
||||||
|
{"client_id": "client-allowed", "client_name": "Allowed App", "metadata": map[string]interface{}{"tenant_id": "tenant-b", "status": "active"}},
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
mockKeto := new(devMockKetoService)
|
||||||
|
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-a", "view_dev_console").Return(false, nil)
|
||||||
|
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-denied", "view").Return(false, nil)
|
||||||
|
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-b", "view_dev_console").Return(false, nil)
|
||||||
|
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-allowed", "view").Return(true, nil)
|
||||||
|
|
||||||
|
h := &DevHandler{
|
||||||
|
Hydra: &service.HydraAdminService{
|
||||||
|
AdminURL: "http://hydra.test",
|
||||||
|
HTTPClient: &http.Client{Transport: transport},
|
||||||
|
},
|
||||||
|
Keto: mockKeto,
|
||||||
|
}
|
||||||
|
app := fiber.New()
|
||||||
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
|
tenantID := "tenant-a"
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||||
|
ID: "user-1",
|
||||||
|
Role: domain.RoleUser,
|
||||||
|
TenantID: &tenantID,
|
||||||
|
})
|
||||||
|
return c.Next()
|
||||||
|
})
|
||||||
|
app.Get("/api/v1/dev/clients", h.ListClients)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
||||||
|
resp, _ := app.Test(req, -1)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var result clientListResponse
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
if assert.Len(t, result.Items, 1) {
|
||||||
|
assert.Equal(t, "client-allowed", result.Items[0].ID)
|
||||||
|
}
|
||||||
|
mockKeto.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateClient_ReservedSystemNameForbidden(t *testing.T) {
|
func TestCreateClient_ReservedSystemNameForbidden(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
t.Fatalf("hydra should not be called when reserved system name is rejected")
|
t.Fatalf("hydra should not be called when reserved system name is rejected")
|
||||||
@@ -1310,6 +1358,133 @@ func TestListAuditLogs_RPAdminScope(t *testing.T) {
|
|||||||
assert.Equal(t, "evt-1", result.Items[0].EventID)
|
assert.Equal(t, "evt-1", result.Items[0].EventID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListAuditLogs_UserAllowedByRPAuditPermission(t *testing.T) {
|
||||||
|
auditRepo := &mockAuditRepo{
|
||||||
|
logs: []domain.AuditLog{
|
||||||
|
{
|
||||||
|
EventID: "evt-allowed",
|
||||||
|
EventType: "POST /api/v1/dev/clients/client-allowed/secret/rotate",
|
||||||
|
Status: "success",
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
Details: `{"target_id":"client-allowed","tenant_id":"tenant-a","action":"ROTATE_SECRET"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
EventID: "evt-denied",
|
||||||
|
EventType: "POST /api/v1/dev/clients/client-denied/secret/rotate",
|
||||||
|
Status: "success",
|
||||||
|
Timestamp: time.Now().UTC().Add(-time.Minute),
|
||||||
|
Details: `{"target_id":"client-denied","tenant_id":"tenant-b","action":"ROTATE_SECRET"}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
if r.URL.Path == "/clients" {
|
||||||
|
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
||||||
|
{"client_id": "client-allowed", "client_name": "Allowed App", "metadata": map[string]interface{}{"tenant_id": "tenant-a"}},
|
||||||
|
{"client_id": "client-denied", "client_name": "Denied App", "metadata": map[string]interface{}{"tenant_id": "tenant-b"}},
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
mockKeto := new(devMockKetoService)
|
||||||
|
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-allowed", "view_audit_logs").Return(true, nil)
|
||||||
|
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-denied", "view_audit_logs").Return(false, nil)
|
||||||
|
|
||||||
|
h := &DevHandler{
|
||||||
|
Hydra: &service.HydraAdminService{
|
||||||
|
AdminURL: "http://hydra.test",
|
||||||
|
HTTPClient: &http.Client{Transport: transport},
|
||||||
|
},
|
||||||
|
AuditRepo: auditRepo,
|
||||||
|
Keto: mockKeto,
|
||||||
|
}
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
tenantID := "tenant-a"
|
||||||
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||||
|
ID: "user-1",
|
||||||
|
Role: domain.RoleUser,
|
||||||
|
TenantID: &tenantID,
|
||||||
|
})
|
||||||
|
return c.Next()
|
||||||
|
})
|
||||||
|
app.Get("/api/v1/dev/audit-logs", h.ListAuditLogs)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/audit-logs?limit=50", nil)
|
||||||
|
resp, _ := app.Test(req, -1)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var result devAuditListResponse
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
if assert.Len(t, result.Items, 1) {
|
||||||
|
assert.Equal(t, "evt-allowed", result.Items[0].EventID)
|
||||||
|
}
|
||||||
|
mockKeto.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListConsents_UserAllowedByRPAdminsRelation(t *testing.T) {
|
||||||
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||||
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
|
"client_id": "client-1",
|
||||||
|
"client_name": "App One",
|
||||||
|
"metadata": map[string]any{
|
||||||
|
"tenant_id": "tenant-1",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
mockKeto := new(devMockKetoService)
|
||||||
|
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_consents").Return(true, nil)
|
||||||
|
|
||||||
|
h := &DevHandler{
|
||||||
|
Hydra: &service.HydraAdminService{
|
||||||
|
AdminURL: "http://hydra.test",
|
||||||
|
HTTPClient: &http.Client{Transport: transport},
|
||||||
|
},
|
||||||
|
ConsentRepo: &mockConsentRepo{
|
||||||
|
consents: []domain.ClientConsent{
|
||||||
|
{
|
||||||
|
ClientID: "client-1",
|
||||||
|
Subject: "subject-1",
|
||||||
|
GrantedScopes: []string{"openid", "profile"},
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Keto: mockKeto,
|
||||||
|
}
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
tenantID := "tenant-1"
|
||||||
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||||
|
ID: "user-1",
|
||||||
|
Role: domain.RoleUser,
|
||||||
|
TenantID: &tenantID,
|
||||||
|
})
|
||||||
|
return c.Next()
|
||||||
|
})
|
||||||
|
app.Get("/api/v1/dev/consents", h.ListConsents)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/consents?client_id=client-1", nil)
|
||||||
|
resp, _ := app.Test(req, -1)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var result consentListResponse
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
if assert.Len(t, result.Items, 1) {
|
||||||
|
assert.Equal(t, "client-1", result.Items[0].ClientID)
|
||||||
|
assert.Equal(t, "subject-1", result.Items[0].Subject)
|
||||||
|
}
|
||||||
|
mockKeto.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
func TestListClientRelations_RPAdminAllowedByViewRelationshipsPermission(t *testing.T) {
|
func TestListClientRelations_RPAdminAllowedByViewRelationshipsPermission(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||||
@@ -1330,7 +1505,7 @@ func TestListClientRelations_RPAdminAllowedByViewRelationshipsPermission(t *test
|
|||||||
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "config_editor", "").Return([]service.RelationTuple{
|
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "config_editor", "").Return([]service.RelationTuple{
|
||||||
{Object: "client-1", Relation: "config_editor", SubjectID: "User:user-2"},
|
{Object: "client-1", Relation: "config_editor", SubjectID: "User:user-2"},
|
||||||
}, nil)
|
}, nil)
|
||||||
for _, relation := range []string{"admins", "creator", "secret_rotator", "jwks_viewer", "jwks_operator", "consent_viewer", "consent_revoker", "relationship_viewer", "status_operator"} {
|
for _, relation := range []string{"admins", "creator", "secret_rotator", "jwks_viewer", "jwks_operator", "consent_viewer", "consent_revoker", "relationship_viewer", "audit_viewer", "status_operator"} {
|
||||||
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", relation, "").Return([]service.RelationTuple{}, nil)
|
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", relation, "").Return([]service.RelationTuple{}, nil)
|
||||||
}
|
}
|
||||||
mockKratos := new(devMockKratosAdmin)
|
mockKratos := new(devMockKratosAdmin)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ var defaultRelyingPartyOperatorRelations = []string{
|
|||||||
"consent_viewer",
|
"consent_viewer",
|
||||||
"consent_revoker",
|
"consent_revoker",
|
||||||
"relationship_viewer",
|
"relationship_viewer",
|
||||||
|
"audit_viewer",
|
||||||
"status_operator",
|
"status_operator",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -270,11 +270,6 @@ function AppLayout() {
|
|||||||
);
|
);
|
||||||
const displayRoleKey = profile?.role || currentRole;
|
const displayRoleKey = profile?.role || currentRole;
|
||||||
|
|
||||||
const isDevConsoleAllowed = [
|
|
||||||
"super_admin",
|
|
||||||
"tenant_admin",
|
|
||||||
"rp_admin",
|
|
||||||
].includes(currentRole);
|
|
||||||
const expiresAtSec = auth.user?.expires_at;
|
const expiresAtSec = auth.user?.expires_at;
|
||||||
const remainingMs =
|
const remainingMs =
|
||||||
typeof expiresAtSec === "number" ? expiresAtSec * 1000 - nowMs : null;
|
typeof expiresAtSec === "number" ? expiresAtSec * 1000 - nowMs : null;
|
||||||
@@ -360,24 +355,23 @@ function AppLayout() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{isDevConsoleAllowed &&
|
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
|
||||||
navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
|
<NavLink
|
||||||
<NavLink
|
key={to}
|
||||||
key={to}
|
to={to}
|
||||||
to={to}
|
className={({ isActive }) =>
|
||||||
className={({ isActive }) =>
|
[
|
||||||
[
|
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
|
||||||
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
|
isActive
|
||||||
isActive
|
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
|
||||||
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
|
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
|
||||||
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
|
].join(" ")
|
||||||
].join(" ")
|
}
|
||||||
}
|
>
|
||||||
>
|
<Icon size={18} />
|
||||||
<Icon size={18} />
|
<span>{t(labelKey, labelFallback)}</span>
|
||||||
<span>{t(labelKey, labelFallback)}</span>
|
</NavLink>
|
||||||
</NavLink>
|
))}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useAuth } from "react-oidc-context";
|
import { useAuth } from "react-oidc-context";
|
||||||
import { Navigate, Outlet } from "react-router-dom";
|
import { Navigate, Outlet } from "react-router-dom";
|
||||||
import { t } from "../../lib/i18n";
|
|
||||||
import { resolveProfileRole } from "../../lib/role";
|
|
||||||
|
|
||||||
export default function AuthGuard() {
|
export default function AuthGuard() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
@@ -18,39 +16,5 @@ export default function AuthGuard() {
|
|||||||
return <Navigate to="/login" replace />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedRole = resolveProfileRole(
|
|
||||||
auth.user?.profile as Record<string, unknown> | undefined,
|
|
||||||
);
|
|
||||||
const isTenantMember =
|
|
||||||
normalizedRole === "user" || normalizedRole === "tenant_member";
|
|
||||||
|
|
||||||
if (isTenantMember) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen grid place-items-center bg-background text-foreground p-6">
|
|
||||||
<div className="max-w-lg w-full rounded-xl border border-border bg-card p-6 space-y-4">
|
|
||||||
<h1 className="text-xl font-semibold">
|
|
||||||
{t("msg.dev.auth.access_denied_title", "접근 권한이 없습니다.")}
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t(
|
|
||||||
"msg.dev.auth.access_denied_description",
|
|
||||||
"DevFront는 관리자 전용 화면입니다. 권한이 필요하면 관리자에게 요청해 주세요.",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90"
|
|
||||||
onClick={() => {
|
|
||||||
auth.removeUser();
|
|
||||||
window.location.href = "/login";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("ui.common.back_to_login", "로그인으로 돌아가기")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,12 +38,17 @@ import {
|
|||||||
} from "../../components/ui/table";
|
} from "../../components/ui/table";
|
||||||
import { fetchClients, fetchDevStats } from "../../lib/devApi";
|
import { fetchClients, fetchDevStats } from "../../lib/devApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
|
import { resolveProfileRole } from "../../lib/role";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
function ClientsPage() {
|
function ClientsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const hasAccessToken = Boolean(auth.user?.access_token);
|
const hasAccessToken = Boolean(auth.user?.access_token);
|
||||||
|
const role = resolveProfileRole(
|
||||||
|
auth.user?.profile as Record<string, unknown> | undefined,
|
||||||
|
);
|
||||||
|
const canCreateClient = role !== "user" && role !== "tenant_member";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@@ -168,16 +173,18 @@ function ClientsPage() {
|
|||||||
)}
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden items-center gap-2 md:flex">
|
{canCreateClient && (
|
||||||
<Button
|
<div className="hidden items-center gap-2 md:flex">
|
||||||
size="sm"
|
<Button
|
||||||
className="shadow-lg shadow-primary/30"
|
size="sm"
|
||||||
onClick={() => navigate("/clients/new")}
|
className="shadow-lg shadow-primary/30"
|
||||||
>
|
onClick={() => navigate("/clients/new")}
|
||||||
<Plus className="h-4 w-4" />
|
>
|
||||||
{t("ui.dev.clients.new", "새 클라이언트")}
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
{t("ui.dev.clients.new", "새 클라이언트")}
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex flex-col gap-3">
|
<div className="mt-4 flex flex-col gap-3">
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||||
@@ -217,7 +224,7 @@ function ClientsPage() {
|
|||||||
)}
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="success">
|
<Badge variant="success">
|
||||||
{t("ui.dev.clients.badge.admin_session", "관리자 세션")}
|
{t("ui.dev.clients.badge.dev_session", "DevFront 세션")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -319,12 +326,14 @@ function ClientsPage() {
|
|||||||
<CardTitle className="text-xl font-semibold">
|
<CardTitle className="text-xl font-semibold">
|
||||||
{t("ui.dev.clients.list.title", "클라이언트 목록")}
|
{t("ui.dev.clients.list.title", "클라이언트 목록")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="flex items-center gap-2 md:hidden">
|
{canCreateClient && (
|
||||||
<Button size="sm" onClick={() => navigate("/clients/new")}>
|
<div className="flex items-center gap-2 md:hidden">
|
||||||
<Plus className="h-4 w-4" />
|
<Button size="sm" onClick={() => navigate("/clients/new")}>
|
||||||
{t("ui.dev.clients.new", "새 클라이언트")}
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
{t("ui.dev.clients.new", "새 클라이언트")}
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -350,6 +359,29 @@ function ClientsPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
{filteredClients.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={6}
|
||||||
|
className="h-32 text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-medium text-foreground">
|
||||||
|
{t(
|
||||||
|
"msg.dev.clients.empty",
|
||||||
|
"조회 가능한 RP가 없습니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
{t(
|
||||||
|
"msg.dev.clients.empty_detail",
|
||||||
|
"RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
{filteredClients.map((client) => (
|
{filteredClients.map((client) => (
|
||||||
<TableRow key={client.id} className="bg-card/40">
|
<TableRow key={client.id} className="bg-card/40">
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ test.describe("DevFront role report", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("user(tenant_member) is blocked with 안내 문구", async ({
|
test("user(tenant_member) can enter and sees empty RP list", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
await seedAuth(page, "user");
|
await seedAuth(page, "user");
|
||||||
@@ -29,9 +29,12 @@ test.describe("DevFront role report", () => {
|
|||||||
|
|
||||||
await page.goto("/clients");
|
await page.goto("/clients");
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText(/관리자 전용 화면|administrator only/i),
|
page.getByText(/조회 가능한 RP가 없습니다|No RPs are available/i),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await captureEvidence(page, testInfo, "role-user-blocked");
|
await expect(
|
||||||
|
page.getByText(/연동 앱|Connected Application/i),
|
||||||
|
).toBeVisible();
|
||||||
|
await captureEvidence(page, testInfo, "role-user-empty-rps");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rp_admin sees only assigned Gitea app and its logs", async ({
|
test("rp_admin sees only assigned Gitea app and its logs", async ({
|
||||||
|
|||||||
@@ -59,14 +59,25 @@ test.describe("DevFront security and isolation", () => {
|
|||||||
await expect(page.getByText("Server side App")).not.toBeVisible();
|
await expect(page.getByText("Server side App")).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("tenant_member user is blocked at AuthGuard", async ({ page }) => {
|
test("tenant_member user can enter DevFront and sees empty RP list", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
await seedAuth(page, "tenant_member");
|
await seedAuth(page, "tenant_member");
|
||||||
|
const state = {
|
||||||
|
clients: [] as ReturnType<typeof makeClient>[],
|
||||||
|
consents: [] as Consent[],
|
||||||
|
auditLogsByCursor: undefined,
|
||||||
|
};
|
||||||
|
await installDevApiMock(page, state);
|
||||||
|
|
||||||
await page.goto("/clients");
|
await page.goto("/clients");
|
||||||
await expect(
|
|
||||||
page.getByText(/DevFront는 관리자 전용 화면입니다|administrator access/i),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(page).toHaveURL(/\/clients$/);
|
await expect(page).toHaveURL(/\/clients$/);
|
||||||
|
await expect(
|
||||||
|
page.getByText(/조회 가능한 RP가 없습니다|No RPs are available/i),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: /연동 앱 추가|새 클라이언트|Create/i }),
|
||||||
|
).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rp_admin receives 403 on clients list and sees ForbiddenMessage", async ({
|
test("rp_admin receives 403 on clients list and sees ForbiddenMessage", async ({
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ class RelyingParty implements Namespace {
|
|||||||
consent_viewer: (User | SubjectSet<System, "super_admins">)[]
|
consent_viewer: (User | SubjectSet<System, "super_admins">)[]
|
||||||
consent_revoker: (User | SubjectSet<System, "super_admins">)[]
|
consent_revoker: (User | SubjectSet<System, "super_admins">)[]
|
||||||
relationship_viewer: (User | SubjectSet<System, "super_admins">)[]
|
relationship_viewer: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
audit_viewer: (User | SubjectSet<System, "super_admins">)[]
|
||||||
status_operator: (User | SubjectSet<System, "super_admins">)[]
|
status_operator: (User | SubjectSet<System, "super_admins">)[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +83,7 @@ class RelyingParty implements Namespace {
|
|||||||
this.related.consent_viewer.includes(ctx.subject) ||
|
this.related.consent_viewer.includes(ctx.subject) ||
|
||||||
this.related.consent_revoker.includes(ctx.subject) ||
|
this.related.consent_revoker.includes(ctx.subject) ||
|
||||||
this.related.relationship_viewer.includes(ctx.subject) ||
|
this.related.relationship_viewer.includes(ctx.subject) ||
|
||||||
|
this.related.audit_viewer.includes(ctx.subject) ||
|
||||||
this.related.status_operator.includes(ctx.subject) ||
|
this.related.status_operator.includes(ctx.subject) ||
|
||||||
this.related.parents.traverse((t) => t.permits.view(ctx)) ||
|
this.related.parents.traverse((t) => t.permits.view(ctx)) ||
|
||||||
this.related.parents.traverse((t) => t.permits.view_dev_console(ctx)),
|
this.related.parents.traverse((t) => t.permits.view_dev_console(ctx)),
|
||||||
@@ -126,6 +128,10 @@ class RelyingParty implements Namespace {
|
|||||||
this.related.parents.traverse((t) => t.permits.grant_dev_permissions(ctx)) ||
|
this.related.parents.traverse((t) => t.permits.grant_dev_permissions(ctx)) ||
|
||||||
this.permits.manage(ctx),
|
this.permits.manage(ctx),
|
||||||
|
|
||||||
|
view_audit_logs: (ctx: Context): boolean =>
|
||||||
|
this.related.audit_viewer.includes(ctx.subject) ||
|
||||||
|
this.permits.manage(ctx),
|
||||||
|
|
||||||
change_status: (ctx: Context): boolean =>
|
change_status: (ctx: Context): boolean =>
|
||||||
this.related.status_operator.includes(ctx.subject) ||
|
this.related.status_operator.includes(ctx.subject) ||
|
||||||
this.permits.manage(ctx),
|
this.permits.manage(ctx),
|
||||||
|
|||||||
Reference in New Issue
Block a user