Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ NODE_ENV=development

# EdgeOps Console static embed (npm run build:console → dev/console/build)
EDGEOPS_CONSOLE_PATH=dev/console/build # must be absolute path
EDGEOPS_CONSOLE_VERSION=v1.0.8
EDGEOPS_CONSOLE_VERSION=v1.0.9
# EDGEOPS_CONSOLE_REPO=https://github.com/Datasance/edgeops-console
# EDGEOPS_CONSOLE_FLAVOR=datasance

Expand Down
2 changes: 1 addition & 1 deletion .github/actions/set-build-env/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ runs:
shell: bash
run: |
VERSION="${{ env.EDGEOPS_CONSOLE_VERSION }}"
if [ -z "$VERSION" ]; then VERSION="1.0.8"; fi
if [ -z "$VERSION" ]; then VERSION="1.0.9"; fi
echo "EDGEOPS_CONSOLE_VERSION=$VERSION" >> "${GITHUB_ENV}"

REPO="${{ env.EDGEOPS_CONSOLE_REPO }}"
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
FROM node:24-bookworm@sha256:fdddfb3e688158251943d52eba361de991548f6814007acba4917ae6b512d6be AS console-builder

ARG EDGEOPS_CONSOLE_REPO=https://github.com/Datasance/edgeops-console
ARG EDGEOPS_CONSOLE_VERSION=v1.0.8
ARG EDGEOPS_CONSOLE_VERSION=v1.0.9
ARG EDGEOPS_CONSOLE_FLAVOR=datasance

RUN apt-get update \
Expand Down Expand Up @@ -50,7 +50,7 @@ RUN npm pack
# ubi9/nodejs-24-minimal:latest — pin manifest list digest for reproducible multi-arch builds
FROM registry.access.redhat.com/ubi9/nodejs-24-minimal@sha256:5f1ac8eab93c93eb2227f4ee7822668b312ee292d122dddd580bee8f17359c2f

ARG EDGEOPS_CONSOLE_VERSION=v1.0.8
ARG EDGEOPS_CONSOLE_VERSION=v1.0.9
ARG IMAGE_REGISTRY
ARG OCI_SOURCE_REPO
ARG CONTROLLER_DISTRIBUTION=iofog
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Local Docker build — mirrors CI/release build-args (see .github/actions/set-build-env).
# Override any variable: make build FLAVOR=iofog EDGEOPS_CONSOLE_VERSION=v1.0.8
# Override any variable: make build FLAVOR=iofog EDGEOPS_CONSOLE_VERSION=v1.0.9

FLAVOR ?= datasance
IMAGE_NAME ?= controller
Expand All @@ -25,7 +25,7 @@ else
$(error FLAVOR must be "datasance" or "iofog", got "$(FLAVOR)")
endif

EDGEOPS_CONSOLE_VERSION ?= v1.0.8
EDGEOPS_CONSOLE_VERSION ?= v1.0.9

IMAGE_REF = $(IMAGE_REGISTRY)/$(IMAGE_NAME):$(DOCKER_TAG)

Expand Down
2 changes: 1 addition & 1 deletion scripts/build-console-dev.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const CONSOLE_DIR = path.join(DEV_DIR, 'console')
const BUILD_OUT = path.join(CONSOLE_DIR, 'build')

const REPO = process.env.EDGEOPS_CONSOLE_REPO || 'https://github.com/Datasance/edgeops-console'
const VERSION = process.env.EDGEOPS_CONSOLE_VERSION || 'v1.0.8'
const VERSION = process.env.EDGEOPS_CONSOLE_VERSION || 'v1.0.9'
const FLAVOR = process.env.EDGEOPS_CONSOLE_FLAVOR || 'datasance'

function normalizeTag (version) {
Expand Down
8 changes: 8 additions & 0 deletions src/data/managers/rbac-cache-version-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ class RbacCacheVersionManager extends BaseManager {
return cacheVersion.version || 0
}

async getVersionWithoutTransaction () {
const cacheVersion = await this.getEntity().findOne({ where: { id: 1 } })
if (!cacheVersion) {
return 0
}
return cacheVersion.version || 0
}

_getModelOptions (transaction) {
return { transaction }
}
Expand Down
150 changes: 106 additions & 44 deletions src/lib/rbac/authorizer.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
const RbacRoleBindingManager = require('../../data/managers/rbac-role-binding-manager')
const RbacRoleManager = require('../../data/managers/rbac-role-manager')
const RbacCacheVersionManager = require('../../data/managers/rbac-cache-version-manager')
const transactionRunner = require('../../helpers/transaction-runner')
const logger = require('../../logger')


// Simple in-memory cache for authorization decisions
// Key format: `${subjectKind}:${subjectName}:${apiGroup}:${resource}:${verb}:${resourceName}`
const authCache = new Map()
const CACHE_TTL = 5 * 60 * 1000 // 5 minutes
const MAX_CACHE_SIZE = 10000
const VERSION_CHECK_INTERVAL_MS = 1000

// Track last known cache version to detect changes across instances
let lastKnownVersion = null
let lastVersionCheckAt = 0

/**
* Check if a value matches a pattern (supports wildcard *)
Expand All @@ -38,6 +40,44 @@ function matchesArray (value, array) {
return array.some(item => matchesPattern(value, item))
}

function buildCacheKey (subjects, apiGroup, resource, verb, resourceName) {
return `${JSON.stringify(subjects)}:${apiGroup}:${resource}:${verb}:${resourceName || ''}`
}

function getCachedResult (cacheKey) {
const cached = authCache.get(cacheKey)
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.result
}
return null
}

function storeCachedResult (cacheKey, result) {
if (authCache.size < MAX_CACHE_SIZE) {
authCache.set(cacheKey, { result, timestamp: Date.now() })
}
}

function isVersionCheckFresh () {
return lastKnownVersion !== null &&
(Date.now() - lastVersionCheckAt) < VERSION_CHECK_INTERVAL_MS
}

async function ensureVersionFresh () {
try {
const currentVersion = await RbacCacheVersionManager.getVersionWithoutTransaction()
if (lastKnownVersion !== null && currentVersion !== lastKnownVersion) {
logger.info('Cache version changed - clearing cache')
authCache.clear()
}
lastKnownVersion = currentVersion
} catch (error) {
logger.warn(`Error checking cache version: ${error.message}`)
} finally {
lastVersionCheckAt = Date.now()
}
}

/**
* Resolve all rules for a subject
*/
Expand Down Expand Up @@ -92,47 +132,26 @@ function evaluateRule (rule, apiGroup, resource, verb, resourceName) {
}

/**
* Authorize a request
* @param {Array} subjects - Array of subjects {kind, name}
* @param {string} apiGroup - API group (empty string for core)
* @param {string} resource - Resource name (e.g., 'microservices')
* @param {string} verb - Verb (e.g., 'get', 'create', 'patch')
* @param {string} resourceName - Optional resource instance name (e.g., microservice UUID)
* @param {Object} transaction - Database transaction
* @returns {Promise<{allowed: boolean, reason?: string}>}
* Full authorization path (requires a database transaction).
* Used on cache miss after a cheap version check.
*/
async function authorize (subjects, apiGroup, resource, verb, resourceName, transaction) {
if (!subjects || !Array.isArray(subjects) || subjects.length === 0) {
return { allowed: false, reason: 'No subjects provided' }
}

// Check if cache version has changed (for multi-instance cache invalidation)
try {
const currentVersion = await RbacCacheVersionManager.getVersion(transaction)
if (lastKnownVersion !== null && currentVersion !== lastKnownVersion) {
// Cache version changed - clear local cache
logger.info('Cache version changed - clearing cache')
authCache.clear()
}
lastKnownVersion = currentVersion
} catch (error) {
// Log error but continue - if version check fails, we'll just skip cache version check
logger.warn(`Error checking cache version: ${error.message}`)
}

// Check cache
const cacheKey = `${JSON.stringify(subjects)}:${apiGroup}:${resource}:${verb}:${resourceName || ''}`
const cached = authCache.get(cacheKey)
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.result
const cacheKey = buildCacheKey(subjects, apiGroup, resource, verb, resourceName)
const cached = getCachedResult(cacheKey)
if (cached) {
return cached
}

// Check system roles first (Admin, SRE, Developer, Viewer)
// These work directly without RoleBindings when OIDC group/role name matches
for (const subject of subjects) {
if (subject.kind === 'Group' && subject.name) {
const roleName = subject.name.toLowerCase()

// Check if it matches a system role
if (RbacRoleManager.isSystemRole(roleName)) {
const systemRole = RbacRoleManager.getSystemRole(roleName)
Expand All @@ -143,10 +162,7 @@ async function authorize (subjects, apiGroup, resource, verb, resourceName, tran
try {
if (evaluateRule(rule, apiGroup, resource, verb, resourceName)) {
const result = { allowed: true, reason: `${roleName} system role has permission` }
// Cache result
if (authCache.size < MAX_CACHE_SIZE) {
authCache.set(cacheKey, { result, timestamp: Date.now() })
}
storeCachedResult(cacheKey, result)
return result
}
} catch (ruleError) {
Expand Down Expand Up @@ -191,10 +207,7 @@ async function authorize (subjects, apiGroup, resource, verb, resourceName, tran
try {
if (evaluateRule(rule, apiGroup, resource, verb, resourceName)) {
const result = { allowed: true, reason: 'Rule matched' }
// Cache result
if (authCache.size < MAX_CACHE_SIZE) {
authCache.set(cacheKey, { result, timestamp: Date.now() })
}
storeCachedResult(cacheKey, result)
return result
}
} catch (ruleError) {
Expand All @@ -213,13 +226,45 @@ async function authorize (subjects, apiGroup, resource, verb, resourceName, tran

// Deny by default
const result = { allowed: false, reason: 'Authorization denied: You do not have permission to perform this action. Please contact your administrator.' }
// Cache result
if (authCache.size < MAX_CACHE_SIZE) {
authCache.set(cacheKey, { result, timestamp: Date.now() })
}
storeCachedResult(cacheKey, result)
return result
}

/**
* Authorize a request with cache fast path (no DB on cache hit within version window).
* @param {Array} subjects - Array of subjects {kind, name}
* @param {string} apiGroup - API group (empty string for core)
* @param {string} resource - Resource name (e.g., 'microservices')
* @param {string} verb - Verb (e.g., 'get', 'create', 'patch')
* @param {string} resourceName - Optional resource instance name (e.g., microservice UUID)
* @returns {Promise<{allowed: boolean, reason?: string}>}
*/
async function authorizeRequest (subjects, apiGroup, resource, verb, resourceName) {
if (!subjects || !Array.isArray(subjects) || subjects.length === 0) {
return { allowed: false, reason: 'No subjects provided' }
}

const cacheKey = buildCacheKey(subjects, apiGroup, resource, verb, resourceName)

if (isVersionCheckFresh()) {
const cached = getCachedResult(cacheKey)
if (cached) {
return cached
}
} else {
await ensureVersionFresh()
const cached = getCachedResult(cacheKey)
if (cached) {
return cached
}
}

return transactionRunner.runInTransaction(
(transaction) => authorize(subjects, apiGroup, resource, verb, resourceName, transaction),
{ label: 'rbac-authorize' }
)
}

/**
* Clear authorization cache
*/
Expand All @@ -239,13 +284,30 @@ function cleanCache () {
}
}

function _resetStateForTests () {
authCache.clear()
lastKnownVersion = null
lastVersionCheckAt = 0
}

function _setVersionCheckStateForTests ({ lastKnownVersion: version, lastVersionCheckAt: checkedAt } = {}) {
if (version !== undefined) {
lastKnownVersion = version
}
if (checkedAt !== undefined) {
lastVersionCheckAt = checkedAt
}
}

// Clean cache every 10 minutes
setInterval(cleanCache, 10 * 60 * 1000)

module.exports = {
authorize,
authorizeRequest,
clearCache,
cleanCache
cleanCache,
_resetStateForTests,
_setVersionCheckStateForTests,
VERSION_CHECK_INTERVAL_MS
}


49 changes: 18 additions & 31 deletions src/lib/rbac/middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ const config = require('../../config')
const db = require('../../data/models')
const { getOidcSettings } = require('../../config/oidc')
const { PASSWORD_CHANGE_REQUIRED_CLAIM } = require('../../services/auth-token-service')
const { runInTransaction } = require('../../helpers/transaction-runner')

const PASSWORD_CHANGE_ALLOWLIST = [
{ method: 'GET', path: '/api/v3/user/profile' },
Expand Down Expand Up @@ -319,16 +318,12 @@ function requirePermission (resource, verb) {
const finalVerb = verb || routeDef.verb
const resourceName = routeDef.resourceName

const authResult = await runInTransaction(
(transaction) => authorizer.authorize(
subjects,
routeDef.apiGroup || '',
finalResource,
finalVerb,
resourceName,
transaction
),
{ label: 'rbac-authorize' }
const authResult = await authorizer.authorizeRequest(
subjects,
routeDef.apiGroup || '',
finalResource,
finalVerb,
resourceName
)

if (!authResult.allowed) {
Expand Down Expand Up @@ -410,16 +405,12 @@ async function authorizeWebSocket (req, token) {
subjects: subjects
})

const authResult = await runInTransaction(
(transaction) => authorizer.authorize(
subjects,
routeDef.apiGroup || '',
routeDef.resource,
routeDef.verb,
routeDef.resourceName,
transaction
),
{ label: 'rbac-authorize-ws' }
const authResult = await authorizer.authorizeRequest(
subjects,
routeDef.apiGroup || '',
routeDef.resource,
routeDef.verb,
routeDef.resourceName
)

logger.debug(`WebSocket authorization result:`, {
Expand Down Expand Up @@ -571,16 +562,12 @@ function protect (_roles) {
return callback()
}

const authResult = await runInTransaction(
(transaction) => authorizer.authorize(
subjects,
routeDef.apiGroup || '',
routeDef.resource,
routeDef.verb,
routeDef.resourceName,
transaction
),
{ label: 'rbac-protect' }
const authResult = await authorizer.authorizeRequest(
subjects,
routeDef.apiGroup || '',
routeDef.resource,
routeDef.verb,
routeDef.resourceName
)

if (!authResult.allowed) {
Expand Down
Loading