Repository navigation
Releases: FezVrasta/ha-rbac
Release list
v0.26.0
Three dashboard fixes, from @peterwang02 and @modert, plus one in the role editor reported by @dimer47.
Upgrade if you hide dashboards from a role and edit that role in the panel. The editor was undoing your denials.
Saving a role no longer drops its dashboard denials
apps.deny is one list that the panel writes from two tables: a checkbox per app, and the "Can open" column of the Dashboards table. Saving read only the first, so every dashboard denial was dropped. Unticking "Can open" redrew unticked and granted the dashboard straight back, and opening a role that already denied a dashboard and pressing Save removed the denial.
apps.allow was cleared on every save too, since the editor has no control for it. A role with an allow list was widened by being saved.
The list is now built from both tables, and anything the form draws no box for, like a system panel or a dashboard that isn't installed right now, is carried through as it was. If you set apps.deny or apps.allow through the role API on an earlier version and saved the role from the panel afterwards, check them: they may have been cleared.
A restricted account lands on a dashboard it can open
A role that hid the household's default dashboard left that account on the loading screen. The frontend couldn't find its default panel, and the empty notfound panel it falls back to was filtered out as well. notfound is now always kept, and a default panel the account can't see is swapped, in that account's response only, for the first dashboard it can open. Stored preferences are untouched.
The dashboard config over REST follows the same rules
The Dashboards capability granted a dashboard's config over the websocket and refused it over REST, and denying one dashboard refused only its websocket spelling. Both transports now agree: the capability covers GET /api/lovelace/dashboard/<url_path>, a denied dashboard is refused there too, and the response is scrubbed of entities the role can't read the same way the websocket one is. Home Assistant versions that don't serve that route are unaffected.
Still an alpha. Two adversarial reviews by its author, no external audit yet. Try it on something that isn't your front door.
v0.25.0
Two fixes, both reported and written by @ounis in #48 and #50, plus the follow-ups that came out of reviewing them.
Upgrade if you grant dashboards. The first of these is a privilege escalation, and it is the reason to take this release.
A dashboard no longer hands out control
Granting a role a dashboard at the control level gave it control of every entity drawn on that dashboard — including entities the role's own entity list never named, and was written to withhold. Dashboards get edited, so anyone who could edit a granted dashboard could hand a restricted role control of anything by dropping it on there. A non-admin could do it to themselves, if their role holds the Dashboards capability.
A dashboard grant now widens reading and nothing else. Control of an entity comes from the role's entity list, and from nowhere else. Denials still win, as they always did.
This changes what your roles do. If you were using a control dashboard as a working control surface — a kitchen tablet, a kid's screen — the people holding it can still see everything on it, but can no longer operate anything the role does not name itself. Name those entities in the role's entity list to give the control back deliberately.
The level is gone from the panel along with the behaviour: a dashboard is now opened empty or carries its contents. Roles you already saved at control are read as content — they keep working, they are not rejected, and there is nothing to re-save.
The companion app no longer hangs after login
A non-admin's Home Assistant Companion app hung on the loading screen right after signing in. Admins were fine, and browsers were mostly fine, which is what made it easy to miss: a browser fetches static assets with no token, so they resolved as anonymous and were forwarded untouched. The app attaches its token to the same requests, which put them on the filtered path.
There, anything that could not be parsed as JSON was refused outright unless its content type was on a short list — and the PWA manifest, Home Assistant's own plain-text 404s and the frontend shell were not on it. The app cannot start without them, so it sat there.
Responses are still refused by default; each exemption is now earned, either by a shape with nowhere to put an entity's state, or by provenance — a body Home Assistant handed straight off disk cannot be live state whatever its type, which is the half that stays closed as new static content types appear. Home Assistant's own 404s and 403s are forwarded as the errors they are rather than remanufactured as a 403, which the app read as an auth failure.
text/plain is deliberately not exempt by type: a rendered template is entity state in plain text.
Devices get a URL that works, when there was no port to correct
v0.24.0 pointed the auto-detected internal URL at the proxy, by swapping the hidden port for the proxy's. That missed the case where Home Assistant advertises those URLs with no port at all — nothing to swap, so the app was handed a portless URL, fell back to port 80, and could not connect.
The whole address is now rebuilt to name the proxy's port. Your external URL is not touched: it says how your instance is reached from outside, and a LAN port has no business in it.
Large downloads are not held in memory
Backups and media files served with a length are streamed rather than buffered whole, so a big download costs the proxy a buffer rather than the whole file. Small replies keep their original framing, which is what the companion app needs to reuse a connection without losing a request to it.
Still an alpha. Two adversarial reviews by its author, no external audit yet. Try it on something that isn't your front door.
v0.24.0
One fix, reported and written by @jan-tdy in #46.
Devices that are handed a URL get one that works
An ESPHome voice satellite could not fetch the audio for its own spoken reply once this integration was running. The reply was generated, the proxy was never asked for it, and the satellite played nothing.
get_url() returns the internal URL you configured under Settings > System >
Network, and falls back to Home Assistant's own address and port when you have not configured one. That port is the hidden loopback port this integration moves Home Assistant to so the proxy can take the public one — so the fallback named an address nothing off the machine can reach. A browser was never affected, because a person types the address in. Anything handed a URL was: the TTS path most concretely, while ordinary ESPHome traffic goes over the native API and never noticed.
The auto-detected internal URL now names the proxy's port. Zeroconf was already corrected for the same underlying reason; this closes the other half.
If you have set an internal URL yourself, nothing changes — that is what the fallback exists for, and it is not ours to second-guess. Nothing is written to storage either: the correction is reapplied each start, follows the port if you reconfigure it, and is taken back if you disable or remove the integration.
Still an alpha. Two adversarial reviews by its author, no external audit yet. Try it on something that isn't your front door.
v0.23.0
A security release. Three reported advisories, all the same hole, plus one found while confirming them. Everyone should take this one.
A denial now holds through a group or a scene
Reported as three separate things — a light that went off with the group it sits in (GHSA-6qwm-3pgq-2w7h), a read-only select that took a new value from a scene (GHSA-8vfg-2wp7-3rh8), a read-only speaker that took a new volume from the group above it (GHSA-rm59-p5pc-35m5) — and one hole underneath all three.
Home Assistant expands an old-style group. entity before the service runs, and that is the one this layer was resolving. Nothing expands the rest. A group helper is an ordinary light. or media_player. entity whose own handler forwards the call to its members; scene.turn_on puts back whatever the scene stored. Both happen inside Home Assistant, after this layer has already answered — so the entities reached that way were never judged at all, and a group was a one-click way around any rule naming one of its members.
The members are now read off the state, where every group platform publishes them, and followed transitively, since a group can hold a group. A call is refused if anything it reaches is refused.
This can newly refuse something that used to work. A role allowed to operate a group but not every member of it is now refused the group — which is the point, but it is a change to what your existing roles do. If a group stops working for someone, the member you have withheld is the reason, and the deny log names it.
scene.apply was already covered: it names the states it reproduces in its payload. A stored scene names nothing but itself, which is what made it invisible here.
A choice rule is judged over REST too
Found while confirming the above. The choice gate added in v0.22.0 read the service name out of the payload, which is where the websocket puts it. Over REST the two halves of the name are path segments and the body carries only the option — so the gate found no call, returned early, and one curl chose any option a role had been refused. The cycling four and set_options went the same way. It now reads the service off the path, the way the rest of the decision already does.
The option field is a picker
Also from #40, reported by the person who asked for the feature. Choosing which options a role may pick was a box you typed a comma-separated list into, and an option is matched exactly — so a rule saying jan where the select says Jan read as "this person may pick their own name" and in fact permitted nothing. Nothing said so: not saving it, and not the denial.
The options now come off the selects the rule targets, in the order each one declares them. You can still type a value, for a rule targeting an area or a label, or an option that does not exist yet.
Still an alpha. Two adversarial reviews by its author, no external audit yet. Try it on something that isn't your front door.
v0.22.0
One new thing, from #40.
A role can be given some of a select's options, not all of them
Being allowed to work a dropdown has always meant being allowed to pick anything on it. Where the options are people — who is announcing, whose room this is — that is usually too much: the only way to stop someone announcing as you was to take the dropdown away, which takes the feature away.
A role can now name the options it may choose on a given select. The entity grant stays exactly as you left it; what narrows is the set of values. Under Choices on a select in the role editor, or as choices on the role.
Targeted the way an attribute rule is — one entity, a domain, an area, a label — so "every input_select in the hallway" is spelled the way it is everywhere else. A select no rule names behaves exactly as before, and a role with no rules never touches the new gate at all.
Three ways to reach an option, and all three are judged
select_option names the one it wants, and it is checked.
select_next, select_previous, select_first and select_last name none of them. Where they land depends on where the select already is, so there is no way to judge them without tracking its position — and guessing would guess in the permissive direction. They are refused on a select a rule covers. Without that, a rule is one extra request to step around.
set_options rewrites the list of options itself, which would let a forbidden option be made permitted first. Also refused.
The payload is walked rather than read at the top level, because execute_script carries its calls in a sequence and a role allowed to run one could otherwise put the call it wanted inside it.
Still an alpha. Two adversarial reviews by its author, no external audit yet. Try it on something that isn't your front door.
v0.21.0
Update now. This fixes seven security issues, two of them ways past a role outright. Only the latest release is supported, so there is nothing to back-port to.
Ways past a role
A one-line template defeated every deny rule. Home Assistant renders target.entity_id server-side, and a resource key was the one place a string reached the policy check without being tested for template syntax first. So {"entity_id": "{{ 'lock.gun_safe' }}"} was recorded as an entity named {{ 'lock.gun_safe' }} — a name no deny rule is written against, which a broad allow matched happily. The request looked bounded, was allowed, and Home Assistant then resolved the template to the lock the role forbids. Any role built the ordinary way, a wide allow with a short deny list, had that deny list bypassed for any entity. A template is unbounded by a rule that already existed; it just was not being applied there.
Some of Home Assistant's own admin-only endpoints read as unrestricted. Tiers are derived from the require_admin decorator, read back off the handler. Several core views carry no decorator and check is_admin in the body instead — and those derived as open, not as the fail-closed default. That covered the whole Supervisor REST surface, which for an admin caller accepts backups/{slug}/restore/full: a household member scoped down by a role could replace the entire instance from a backup. It also covered POST /api/states/{entity_id}, which overwrites what Home Assistant reports for an entity without going near the device.
The obvious fix — treat anything unrecognised as admin — was measured and rejected: it reclassifies 56 routes here, GET /api/states among them, which every restricted dashboard reads. The gate is recognised instead. A handler that names is_admin and a refusal beside it is gating; one that only names it is choosing what to return, which is what the state list does.
Things a role should have hidden
The deny log was going to everyone. Every refusal is fired onto the event bus, carrying the entity ids involved, the command attempted, and the name of whoever was refused. Nothing withheld it from a restricted subscriber, so a scoped-down account could watch every refusal in the house — including the entity ids of things its own role hides entirely.
History ignored attribute rules that named an entity. A history sample carries no entity id of its own, so "hide latitude and longitude on person.jane" was silently skipped there while working correctly everywhere else. The location a role was written to withhold came back in full through a different command.
An area or device could be named where an entity could not. The response filter recognised entity ids and no other resource kind, so an event echoing a service call's original area_id went through untouched.
An integration's own word for a camera named nothing. Frigate asks for a recording with {"camera": "bedroom"}, which is camera.bedroom spelled its way. The gate saw a request naming nothing, and the reply is video, which is streamed rather than filtered — so a denied camera's recordings were readable. A key that is a domain now qualifies its value, confirmed against the registry.
A race in the add-on ingress gate. Two near-simultaneous requests for an add-on the map had not seen yet, and the second was told it was not an add-on at all and forwarded to its web UI unguarded.
And one nobody reported
The proxy built its upstream URL by resolving the request path against the configured host, the way a browser resolves a link. A path beginning with two slashes is a protocol-relative URL naming a host, so //example.com/x replaced the upstream outright: the proxy fetched that address from the Home Assistant machine and relayed the answer. Server-side request forgery reaching the rest of your network and, on a cloud instance, its metadata endpoint — and it happened before any user was resolved, so it needed no login.
This was sitting in the repository's own CodeQL alerts, unreviewed.
What changes for you
Four REST routes become administrator-only: POST and DELETE on /api/states/{entity_id}, and deleting or reloading a config entry. A role that reached those before was reaching something Home Assistant reserves to administrators.
History now drops entities a role cannot read, and applies attribute rules that name an entity or a domain. A dashboard graph that was showing more than it should will show less.
Restricted connections no longer receive the rbac_denied event. The Denials tab is unaffected — it reads an admin-only command.
Still an alpha. Two adversarial reviews by its author, no external audit yet. Try it on something that isn't your front door.
v0.20.0
A fresh companion app auto-detected a Home Assistant it could not connect to. Reported as #19.
The broadcast followed Home Assistant to the hidden port
Home Assistant announces itself on the network — _home-assistant._tcp — with the port it binds. Once this layer moves it to 127.0.0.1:8124, that is the port being announced, and it is the one nothing off the machine can reach. So the companion app found the instance, offered it, and failed to connect; the proxy's address had to be typed in by hand, which is exactly the "nothing has to be repointed" promise this integration is supposed to keep.
The announcement now names the port the proxy answers on, with the internal_url and base_url properties it carries rewritten to match. It happens only after the move has been confirmed — while Home Assistant can still revert to the port it came from, the old announcement is the correct one.
None of this is API Home Assistant promised to keep, so every way it can be absent — zeroconf not loaded, nothing announced yet, a renamed internal — leaves the announcement exactly as Home Assistant left it rather than raising.
Still an alpha. Two adversarial reviews by its author, no external audit yet. Try it on something that isn't your front door.
v0.19.1
The proxy could stop answering after a burst of clients — still bound to its port, still accepting connections, but never replying. Reported as #17.
The proxy was borrowing Home Assistant's connections
Every request and every websocket this layer relays was drawn from Home Assistant's own shared client session — the pool its integrations use to talk to the outside world. That pool lends a hundred connections per host, and a relayed websocket holds one of them for as long as the websocket lives. A hundred open apps, tabs and dashboards is not a large household, and once those hundred were taken there was nothing left to lend.
A forwarded request carries no timeout, deliberately: a camera stream or a large download must not be cut off partway. So the hundred-and-first request did not fail. It waited for a free connection that was never coming. The process stayed bound to the port, so clients saw a request that timed out rather than a connection that was refused — which is what the iOS Companion app reported after its token-refresh loop.
The borrowing was the other half of the problem: connections spent relaying were connections Home Assistant could not spend on its own outbound calls.
The proxy now owns its connections
It opens its own client session, with no cap on how many connections it may hold, and closes it when the proxy stops. Home Assistant's pool is left to the integrations that need it.
Also in this release
The README and the design notes now say how permissions combine when someone holds more than one role: entity, tier and app checks are OR-ed across a user's roles, so the most permissive role wins a conflict, and a deny beats an allow only within the single role that wrote it. Asked in #14.
v0.19.0
Granting someone one script did not let them run it. Reported as #12.
A script you were given now runs
A script's own service is named after it — script.camera_ptz_left — and that is what a dashboard button calls: no target, nothing in the payload but a domain and a service name. Nothing there named an entity, so the check fell back to asking whether the role could control the whole script domain. It could not, so ticking one script granted nothing and the person was told they had no permission for the thing they had just been handed.
A service named after an entity is now judged by that entity. That is the more precise answer wherever it applies — scripts, and the per-notifier services spelled the same way — and the other scripts stay refused. The refusal also names what it refused, so these stop arriving in the deny log with an empty Entities column.
Half that deny log was this layer talking to itself
The report came with a page of system_log denials, which looked like the cause and was not. The frontend writes unhandled JavaScript errors to system_log.write. Every refused script produced an error, the report of that error was refused too, and the two alternated down the log — for a domain that has no entities at all, so no entity grant could ever have satisfied it.
system_log.write writes a log line and touches nothing else, and Home Assistant lets any signed-in user call it, so it is allowed. It is checked after the tier gate: a role that denies it explicitly still wins.
The same call, whichever way it arrives
POST /api/services/{domain}/{service} carries the service name in the URL, where nothing was looking. So every targetless call over REST was refused as unbounded while the identical call over the websocket went through — a script, a notification, anything an agent driving the HTTP API asked for. Both transports now get the same answer, read off Home Assistant's own registered route rather than a path matched by hand. A body naming a resource, including entity_id: all, still stands as unbounded.
Recording writes the script down
The same blind spot from the other side: a recorded button press noted nothing, so the role built from watching someone press it could not press it again.
Verified on the test instance as a restricted user over both transports: the granted script runs, the other stays refused, and homeassistant.turn_off with entity_id: all is still unbounded.
Still an alpha. Two adversarial reviews by its author, no external audit yet. Try it on something that isn't your front door.
v0.18.0
Recording a role showed the person being recorded an empty Home Assistant, so there was nothing for them to touch and the recording came back with nothing in it. Reported as #8.
Recording works
Recording is meant to be a temporary grant of full access: hand the role out, let someone use Home Assistant normally, and keep what they reached for. The request side did that — every command was allowed and noted instead of judged. The reply side never got the message. Results going back over the websocket were still filtered against the role's own rules, the very rules the recording suspends.
So the person being recorded opened Home Assistant to an empty state list and an empty entity registry. No dashboard to browse, no entity to tap, nothing to generate. Stop and keep then added nothing, the role was unchanged, and it looked like the feature simply did nothing.
Measured on the test instance, against a role that allows no entities: 0 of 138 entities visible while recorded, and record/stop logging 0 entities, 0 apps. After the fix, 138 of 138 during, the entities that were reached for written into the role, and enforcement back to exactly those afterwards.
Requests over REST were never affected — that path already stood aside for a recording. It was websocket replies only, which is why it survived this long.
The running total moves on its own
"Seen so far" sat at whatever it was when the page loaded, because nothing re-read it while the recording ran. Saving the role happened to reload everything, which is how you may have found that saving made the number jump.
It now updates by itself every few seconds while a recording is running. Only the count is fetched, so an edit you have not saved yet is left alone.
Also in this release
A loopback bind address with Move Home Assistant for me now explains what will happen instead of refusing to save. Behind a reverse proxy or a tunnel on the same host that combination is the stricter, correct setup, and it was being rejected. If nothing on the host bridges the network it will strand the instance, which is what the warning says. Thanks to @ounis for the report and the fix.
Still an alpha. Two adversarial reviews by its author, no external audit yet. Try it on something that isn't your front door.