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
18 changes: 17 additions & 1 deletion doc/development/editions.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ type Edition interface {
switch a feature on or off at run time (for example when a license
expires) without a restart.

An edition may also implement `edition.Notifier`. Its `Notice()` returns an
`*edition.Notice` (`Severity` "info", "warning" or "danger", `Message`, and
an optional `Link` to a web UI path with `LinkText`), or nil. The server
adds it to `GET /api/v1/auth/me` as `edition_notice` for users who hold
`settings:manage` globally, and the web UI shows it as a banner on every
page; Bor Enterprise uses it to announce an expiring or rejected license.

### Features

`Feature` is a string type. The known features are listed in
Expand Down Expand Up @@ -206,7 +213,16 @@ Settings panels
optional copy button), tables and actions. An action is a button that
sends `Method` to a module URL and reloads the panel; a `Danger`
action requires typing its `ConfirmPhrase`, and `Result: "secret"`
shows a returned token once with a copy control.
shows a returned token once with a copy control. An action with an
`Input` (`ext.PanelInput{Label, Help, MaxBytes}`) first asks for a
multi-line value, typed, pasted or loaded from a local file, and sends
it as the JSON body `{"panel_input": "..."}`; its `Confirm` text is
shown in the same dialog, and a `Danger` action asks for its
`ConfirmPhrase` there too. The generic audit log
redacts `panel_input` (it may be a key or a certificate), so the module
records what matters with `Audit()`. After any action the web UI
re-reads the signed-in session, so features the action switched on or
off show up without a reload.

Storage
: `DB()` returns the server's `*sql.DB` for the module's own tables.
Expand Down
1 change: 1 addition & 0 deletions server/internal/api/audit_middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ var sensitiveKeys = []string{
"passphrase", "private_key", "privatekey", "api_key", "apikey",
"jwt", "auth_token", "access_token", "refresh_token",
"recovery_key", // LUKS recovery keys (belt and braces; the reveal route bypasses this middleware anyway)
"panel_input", // values entered in extension settings panels (licenses, keys, IdP metadata); the extension audits what matters
}

func isSensitiveKey(key string) bool {
Expand Down
23 changes: 23 additions & 0 deletions server/internal/api/audit_middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,26 @@ func TestStatusRecorder(t *testing.T) {
t.Errorf("underlying recorder code = %d, want %d", rec.Code, http.StatusCreated)
}
}

func TestRedactBody(t *testing.T) {
body := map[string]interface{}{
"name": "corp",
"password": "hunter2",
"panel_input": "-----BEGIN PRIVATE KEY-----",
"items": []interface{}{map[string]interface{}{"api_key": "k", "value": "kept"}},
}
redactBody(body)

for _, key := range []string{"password", "panel_input"} {
if body[key] != "[REDACTED]" {
t.Errorf("%s = %v, want [REDACTED]", key, body[key])
}
}
if body["name"] != "corp" {
t.Errorf("name = %v, want it kept", body["name"])
}
item := body["items"].([]interface{})[0].(map[string]interface{})
if item["api_key"] != "[REDACTED]" || item["value"] != "kept" {
t.Errorf("nested item = %v", item)
}
}
3 changes: 3 additions & 0 deletions server/internal/api/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,9 @@ func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
for _, f := range edition.EnabledFeatures(ed) {
resp.Features = append(resp.Features, string(f))
}
if n, ok := ed.(edition.Notifier); ok && grants.Scope("settings", "manage").IsGlobal() {
resp.EditionNotice = n.Notice()
}

w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
Expand Down
5 changes: 5 additions & 0 deletions server/internal/models/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ package models

import (
"time"

"github.com/VuteTech/Bor/server/pkg/edition"
)

// Policy state constants (artifact lifecycle)
Expand Down Expand Up @@ -302,6 +304,9 @@ type MeResponse struct {
Edition string `json:"edition"`
// Features lists the optional features the edition currently enables.
Features []string `json:"features"`
// EditionNotice is the edition's message for administrators (only for
// users who hold settings:manage globally), e.g. an expiring license.
EditionNotice *edition.Notice `json:"edition_notice,omitempty"`
}

// PermissionScope describes where a user holds one permission.
Expand Down
19 changes: 19 additions & 0 deletions server/pkg/edition/edition.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,22 @@ func EnabledFeatures(e Edition) []Feature {
}
return out
}

// Notice is a message about the edition itself that administrators should
// act on, such as a license about to expire.
type Notice struct {
// Severity is "info", "warning" or "danger".
Severity string `json:"severity"`
Message string `json:"message"`
// Link optionally points to the web UI page where it is resolved (a
// path such as "/settings?tab=ext-license"), shown as LinkText.
Link string `json:"link,omitempty"`
LinkText string `json:"link_text,omitempty"`
}

// Notifier is optionally implemented by an Edition that has something to
// tell administrators. The web UI shows the notice to users who hold
// settings:manage; nil means there is nothing to show.
type Notifier interface {
Notice() *Notice
}
16 changes: 16 additions & 0 deletions server/pkg/ext/ext.go
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,22 @@ type PanelAction struct {
// shown once with a copy control), "message" (the "message" member) or
// "" (nothing).
Result string `json:"result,omitempty"`
// Input, when set, asks the administrator for a value before the
// request is sent; it is sent as the JSON body {"panel_input": "..."}.
// The generic audit log redacts it (it may be a key or a certificate),
// so the module records what matters itself.
Input *PanelInput `json:"input,omitempty"`
}

// PanelInput is a multi-line text value an action asks for, which can also
// be loaded from a local file (a license, a certificate, IdP metadata).
type PanelInput struct {
Label string `json:"label"`
// Help is shown below the field.
Help string `json:"help,omitempty"`
// MaxBytes rejects larger values in the browser (0: no limit). The
// module must enforce its own limit as well.
MaxBytes int `json:"max_bytes,omitempty"`
}

// ─── Compliance results, certificates, SIEM ─────────────────────────────
Expand Down
44 changes: 43 additions & 1 deletion server/web/frontend/src/Shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
Button,
Spinner,
Tooltip,
AlertActionLink,
} from "@patternfly/react-core";
import UserIcon from "@patternfly/react-icons/dist/esm/icons/user-icon";
import BarsIcon from "@patternfly/react-icons/dist/esm/icons/bars-icon";
Expand All @@ -39,7 +40,9 @@ import MoonIcon from "@patternfly/react-icons/dist/esm/icons/moon-icon";
import DesktopIcon from "@patternfly/react-icons/dist/esm/icons/desktop-icon";
import AdjustIcon from "@patternfly/react-icons/dist/esm/icons/adjust-icon";

import { checkSession, logout, getMFAStatus, getPublicConfig, UserInfo } from "./apiClient/authApi";
import { checkSession, logout, getMFAStatus, getPublicConfig, EditionNoticeInfo, UserInfo } from "./apiClient/authApi";
import { SESSION_CHANGED_EVENT } from "./apiClient/extPanelsApi";
import { LiveAlert } from "./components/LiveAlert";
import { getServerVersion } from "./apiClient/systemApi";
import { setPermissions, clearPermissions, hasPermission, hasFeature, FEATURE_COMPLIANCE_REPORTS } from "./apiClient/permissions";
import { onSessionExpired } from "./apiClient/session";
Expand Down Expand Up @@ -183,6 +186,10 @@ export const Shell: React.FC = () => {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [currentUser, setCurrentUser] = useState<string>("");
const [currentUserSource, setCurrentUserSource] = useState<string>("local");
const [editionNotice, setEditionNotice] = useState<EditionNoticeInfo | null>(null);
// Bumped when permissions or features are re-read: they live outside React
// state, so the navigation would otherwise not re-render.
const [, setSessionVersion] = useState(0);
const [authChecked, setAuthChecked] = useState(false);

/* ── Update document title on screen change (WCAG 2.4.2) ── */
Expand All @@ -199,6 +206,7 @@ export const Shell: React.FC = () => {
/* ── After session is established, check if MFA setup is required ── */
const applySession = useCallback(async (user: UserInfo) => {
setPermissions(user.permissions || [], user.permission_scopes, user.features);
setEditionNotice(user.edition_notice ?? null);
setCurrentUser(user.full_name || user.username);
setCurrentUserSource(user.source || "local");
setIsLoggedIn(true);
Expand All @@ -212,6 +220,24 @@ export const Shell: React.FC = () => {
}
}, []);

/* ── Re-read permissions, features and the edition notice when an action
may have changed them (e.g. a license was installed) ── */
useEffect(() => {
const refresh = () => {
checkSession()
.then((user: UserInfo) => {
setPermissions(user.permissions || [], user.permission_scopes, user.features);
setEditionNotice(user.edition_notice ?? null);
setSessionVersion((v) => v + 1);
})
.catch(() => {
/* the session layer handles expiry */
});
};
window.addEventListener(SESSION_CHANGED_EVENT, refresh);
return () => window.removeEventListener(SESSION_CHANGED_EVENT, refresh);
}, []);

/* ── Validate existing session on mount ── */
useEffect(() => {
checkSession()
Expand Down Expand Up @@ -545,6 +571,22 @@ export const Shell: React.FC = () => {
defaultManagedSidebarIsOpen={true}
mainContainerId="bor-main-content"
>
{editionNotice && (
<PageSection padding={{ default: "noPadding" }} style={{ padding: "var(--pf-t--global--spacer--md) var(--pf-t--global--spacer--lg) 0" }}>
<LiveAlert
variant={editionNotice.severity}
isInline
message={editionNotice.message}
actionLinks={
editionNotice.link?.startsWith("/") ? (
<AlertActionLink onClick={() => navigate(editionNotice.link as string)}>
{editionNotice.link_text || "Details"}
</AlertActionLink>
) : undefined
}
/>
</PageSection>
)}
<PageHeader title={routeTitle?.title ?? PAGE_NAMES[activeScreen]} subtitle={routeTitle?.subtitle ?? PAGE_SUBTITLES[activeScreen]} />
<Routes>
<Route index element={<DashboardPage />} />
Expand Down
10 changes: 10 additions & 0 deletions server/web/frontend/src/apiClient/authApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,16 @@ export interface UserInfo {
edition?: string;
features?: string[];
source?: string;
/** The edition's message for administrators, e.g. an expiring license. */
edition_notice?: EditionNoticeInfo;
}

export interface EditionNoticeInfo {
severity: "info" | "warning" | "danger";
message: string;
/** A path in the web UI where the notice is resolved. */
link?: string;
link_text?: string;
}

export async function login(
Expand Down
23 changes: 19 additions & 4 deletions server/web/frontend/src/apiClient/extPanelsApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ export interface PanelAction {
confirm?: string;
/** "secret": show the response's token once; "message": show its message. */
result?: "secret" | "message" | "";
/** Asks for a value first; it is sent as the JSON body {"panel_input": ...}. */
input?: PanelInput;
}

export interface PanelInput {
label: string;
help?: string;
max_bytes?: number;
}

export interface PanelField {
Expand Down Expand Up @@ -88,9 +96,16 @@ export async function fetchPanel(dataUrl: string): Promise<Panel> {

export async function runPanelAction(
action: PanelAction,
value?: string,
): Promise<{ token?: string; message?: string }> {
return apiRequest<{ token?: string; message?: string }>(checkURL(action.url), {
method: action.method,
headers: authHeaders(),
});
const init: RequestInit = { method: action.method, headers: authHeaders() };
if (value !== undefined) init.body = JSON.stringify({ panel_input: value });
const res = await apiRequest<{ token?: string; message?: string }>(checkURL(action.url), init);
// An extension action may change what the edition enables (a license, for
// example); the shell re-reads the session to pick that up.
window.dispatchEvent(new Event(SESSION_CHANGED_EVENT));
return res;
}

/** Dispatched on window when the signed-in session's data may have changed. */
export const SESSION_CHANGED_EVENT = "bor:session-changed";
Loading
Loading