Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
config.js binary
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ build/
.idea/
*.swp
*.swo
.nvim.lua

# OS files
.DS_Store
Thumbs.db

# Python
__pycache__/
.venv/
*.py[cod]
*$py.class
*.so
Expand All @@ -48,4 +50,4 @@ coverage.xml

# Home Assistant configuration
config/*
!config/configuration.yaml
!config/configuration.yaml
111 changes: 105 additions & 6 deletions custom_components/rbac/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""RBAC Middleware for Home Assistant."""
import logging
import os
from typing import Any, Dict, Optional
from typing import Any, Dict, Optional, Callable
from datetime import datetime

import yaml
Expand Down Expand Up @@ -68,7 +68,8 @@ def _load_file():
"services": ["host_reboot", "host_shutdown", "supervisor_update", "supervisor_restart"]
}
},
"entities": {}
"entities": {},
"panels": {}
},
"users": {},
"roles": {
Expand Down Expand Up @@ -111,6 +112,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.data[DOMAIN]["original_async_call"] = hass.services.async_call

_patch_service_registry(hass)
_patch_panel_list(hass)

from . import services
await services.async_setup_services(hass)
Expand All @@ -121,9 +123,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:

user_count = len(access_config.get("users", {}))
_LOGGER.info(f"RBAC Middleware initialized successfully with {user_count} configured users")
from .services import RBACConfigView, RBACUsersView, RBACDomainsView, RBACEntitiesView, RBACServicesView, RBACCurrentUserView, RBACSensorsView, RBACDenyLogView, RBACTemplateEvaluateView, RBACFrontendBlockingView, RBACYamlEditorView

from .services import RBACConfigView, RBACUsersView, RBACDomainsView, RBACEntitiesView, RBACPanelsView, RBACServicesView, RBACCurrentUserView, RBACSensorsView, RBACDenyLogView, RBACTemplateEvaluateView, RBACFrontendBlockingView, RBACYamlEditorView

hass.http.register_view(RBACConfigView())
hass.http.register_view(RBACUsersView())
hass.http.register_view(RBACDomainsView())
Expand All @@ -135,7 +137,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.http.register_view(RBACTemplateEvaluateView())
hass.http.register_view(RBACFrontendBlockingView())
hass.http.register_view(RBACYamlEditorView())

hass.http.register_view(RBACPanelsView())

_LOGGER.info("Registered RBAC API endpoints")

await _register_sidebar_panel(hass)
Expand Down Expand Up @@ -219,6 +222,102 @@ async def _setup_rbac_device(hass: HomeAssistant, config_entry):
except Exception as e:
_LOGGER.warning(f"Could not create device/sensors properly: {e}")

def _patch_panel_list(hass: HomeAssistant):
from homeassistant.components.websocket_api.const import DOMAIN as WS_DOMAIN
from homeassistant.components.websocket_api.connection import ActiveConnection
from homeassistant.core import callback

class RBACFilterPanelActiveConnection:
"Wrapper for connection that filer restricted panels"

__slots__ = [
"hass",
"original_connection",
]

def __init__(self, hass: HomeAssistant, connection: ActiveConnection):
self.hass = hass
self.original_connection = connection

def __getattr__(self, name):
return getattr(self.original_connection, name)

def _send_filtered_data(
self,
data: dict[str, Any],
deny_all: bool,
rbac_include: set[str],
rbac_exclude: set[str],
) -> None:
panels = data["result"]

result = {} if deny_all else panels.copy()

for key, value in panels.items():
if key in rbac_include:
result[key] = value
elif key in rbac_exclude:
result.pop(key, None)

data["result"] = result
return self.original_connection.send_message(data)

def send_message(self, data: dict[str, Any]):
user = self.original_connection.user
user_id = user.id
access_config = self.hass.data.get(DOMAIN, {}).get("access_config", {})

rbac_enabled = access_config.get("enabled", True)
if not rbac_enabled:
_LOGGER.warning(f"RBAC is disabled - no filtering applied")
return self.original_connection.send_message(data)

users = access_config.get("users", {})
user_config = users.get(user_id)

if not user_config:
default_config = access_config["default_restrictions"]
default_rbac_panels = default_config["panels"]

rbac_exclude = set()
for key, access in default_rbac_panels.items():
if access["hide"]:
rbac_exclude.add(key)
# Check default restrictions
return self._send_filtered_data(data, False, {}, rbac_exclude)

user_role = user_config.get("role", "unknown")

roles = access_config.get("roles", {})
role_config = roles.get(user_role, {})

deny_all = role_config.get("deny_all", False)
rbac_panels = role_config.get("permissions", {}).get("panels", {})
rbac_include = set()
rbac_exclude = set()
for key, access in rbac_panels.items():
if access["allow"]:
rbac_include.add(key)
else:
rbac_exclude.add(key)

self._send_filtered_data(data, deny_all, rbac_include, rbac_exclude)


@callback
def rbac_websocket_get_panels(
get_panel_func: Callable[[HomeAssistant, ActiveConnection, dict[str, Any]], None]
) -> Callable[[HomeAssistant, ActiveConnection, dict[str, Any]], None]:
def wrapper(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
new_connection = RBACFilterPanelActiveConnection(hass, connection)
get_panel_func(hass, new_connection, msg)

return wrapper

original_panel_handler, schema = hass.data[WS_DOMAIN]["get_panels"]
hass.data[WS_DOMAIN]["get_panels"] = rbac_websocket_get_panels(original_panel_handler), schema

def _patch_service_registry(hass: HomeAssistant):
"""Patch the service registry to intercept service calls."""
Expand Down
3 changes: 2 additions & 1 deletion custom_components/rbac/access_control.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,12 @@ default_restrictions:
- supervisor_update
- supervisor_restart
entities: {}
panels: {}
users: {}
roles:
admin:
description: Administrator with most permissions
user:
description: Standard user with limited permissions
guest:
description: Guest with minimal permissions
description: Guest with minimal permissions
31 changes: 31 additions & 0 deletions custom_components/rbac/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from homeassistant.helpers import config_validation as cv
from homeassistant.util.json import JsonObjectType
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.frontend import DATA_PANELS
from aiohttp import web

from . import (
Expand Down Expand Up @@ -728,6 +729,36 @@ async def get(self, request):
return self.json({"error": str(e)}, status_code=500)


class RBACPanelsView(HomeAssistantView):
"""Handle RBAC panels API requests."""

url = "/api/rbac/panels"
name = "api:rbac:panels"
requires_auth = True

async def get(self, request):
"""Get all available entities."""
hass = request.app["hass"]
user = request["hass_user"]

# Check admin permissions
if not await _is_admin_user(hass, user.id):
return self.json({
"error": "Admin access required",
"message": "Only administrators can access entity information",
"redirect_url": "/"
}, status_code=403)

try:
all_panels = hass.data[DATA_PANELS]
panels = [key for key in all_panels.keys()]

return self.json(sorted(panels))
except Exception as e:
_LOGGER.error(f"Error getting panels: {e}")
return self.json({"error": str(e)}, status_code=500)


class RBACServicesView(HomeAssistantView):
"""Handle RBAC services API requests."""

Expand Down
140 changes: 70 additions & 70 deletions custom_components/rbac/www/config.js

Large diffs are not rendered by default.

23 changes: 13 additions & 10 deletions frontend/src/components/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export function App() {
users: [],
domains: [],
entities: [],
panels: [],
services: [],
config: null
});
Expand Down Expand Up @@ -144,19 +145,20 @@ export function App() {
}

console.log('Making API calls...');
const [usersRes, domainsRes, entitiesRes, servicesRes, configRes] = await Promise.all([
const [usersRes, domainsRes, entitiesRes, panelsRes, servicesRes, configRes] = await Promise.all([
makeAuthenticatedRequest('/api/rbac/users'),
makeAuthenticatedRequest('/api/rbac/domains'),
makeAuthenticatedRequest('/api/rbac/entities'),
makeAuthenticatedRequest('/api/rbac/panels'),
makeAuthenticatedRequest('/api/rbac/services'),
makeAuthenticatedRequest('/api/rbac/config')
]);

console.log('API responses:', { usersRes, domainsRes, entitiesRes, servicesRes, configRes });
console.log('API responses:', { usersRes, domainsRes, entitiesRes, panelsRes, servicesRes, configRes });

// Check for admin access denied (403)
if (usersRes.status === 403 || domainsRes.status === 403 || entitiesRes.status === 403 ||
servicesRes.status === 403 || configRes.status === 403) {
if (usersRes.status === 403 || domainsRes.status === 403 || entitiesRes.status === 403 ||
panelsRes.status === 403 || servicesRes.status === 403 || configRes.status === 403) {
const errorData = await configRes.json();

// Set admin access denied state
Expand All @@ -178,8 +180,8 @@ export function App() {
}

// Check if any API call returns 404 or indicates integration not configured
if (usersRes.status === 404 || domainsRes.status === 404 || entitiesRes.status === 404 ||
servicesRes.status === 404 || configRes.status === 404) {
if (usersRes.status === 404 || domainsRes.status === 404 || entitiesRes.status === 404 ||
panelsRes.status === 404 || servicesRes.status === 404 || configRes.status === 404) {
setIntegrationConfigured(false);
if (isManualReload) {
setReloading(false);
Expand All @@ -189,20 +191,21 @@ export function App() {
return;
}

if (!usersRes.ok || !domainsRes.ok || !entitiesRes.ok || !servicesRes.ok || !configRes.ok) {
if (!usersRes.ok || !domainsRes.ok || !entitiesRes.ok || !panelsRes.ok || !servicesRes.ok || !configRes.ok) {
throw new Error('Failed to load data from API');
}

const [users, domains, entities, services, config] = await Promise.all([
const [users, domains, entities, panels, services, config] = await Promise.all([
usersRes.json(),
domainsRes.json(),
entitiesRes.json(),
panelsRes.json(),
servicesRes.json(),
configRes.json()
]);

console.log('Loaded data:', { users, domains, entities, services, config });
setData({ users, domains, entities, services, config });
console.log('Loaded data:', { users, domains, entities, panels, services, config });
setData({ users, domains, entities, panels, services, config });
setIntegrationConfigured(true);

// Load enabled state from config
Expand Down
26 changes: 23 additions & 3 deletions frontend/src/components/DefaultRestrictions.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export function DefaultRestrictions({ data, onSuccess, onError }) {
const [loading, setLoading] = useState(false);
const [restrictions, setRestrictions] = useState({
domains: [],
entities: []
entities: [],
panels: []
});

// Initialize restrictions from config
Expand All @@ -22,7 +23,8 @@ export function DefaultRestrictions({ data, onSuccess, onError }) {
const defaultRestrictions = data.config.default_restrictions;
setRestrictions({
domains: Object.keys(defaultRestrictions.domains || {}),
entities: Object.keys(defaultRestrictions.entities || {})
entities: Object.keys(defaultRestrictions.entities || {}),
panels: Object.keys(defaultRestrictions.panels || {})
});
}
}, [data.config]);
Expand All @@ -39,7 +41,8 @@ export function DefaultRestrictions({ data, onSuccess, onError }) {
const defaultRestrictions = {
domains: {},
entities: {},
services: {}
services: {},
panels: {}
};

// Add domain restrictions
Expand All @@ -58,6 +61,13 @@ export function DefaultRestrictions({ data, onSuccess, onError }) {
};
});

// Add entity restrictions
restrictions.panels.forEach(panel => {
defaultRestrictions.panels[panel] = {
hide: true,
};
});

const response = await fetch('/api/rbac/config', {
method: 'POST',
headers: {
Expand Down Expand Up @@ -164,6 +174,16 @@ export function DefaultRestrictions({ data, onSuccess, onError }) {
disabled={loading}
/>
</Col>

<Col xs={24} md={12}>
<AntMultiSelect
options={data.panels || []}
selectedValues={restrictions.panels}
onSelectionChange={(panels) => setRestrictions(prev => ({ ...prev, panels }))}
placeholder="Select panels to restrict..."
disabled={loading}
/>
</Col>
</Row>

<Space style={{ justifyContent: 'flex-end', display: 'flex', width: '100%', marginTop: 24 }}>
Expand Down
Loading