mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
插件页面不是自动公开的数据。billing 显示跨全部源的成本,quota 显示上游余额,
而这两者都不属于持有 user 密钥的人 —— 但 /api/ui-inject 不做任何角色过滤,
所以两者都发给了任何已认证的密钥。实测(不是推断):club 这把 user 密钥拉到了
18672 字节的 billing state,里面是 16871 次请求 / $8.8616 / 21 个源名与各自
成本。
UIPage/UIElement 加 Audience,UIExtension 加 Visibility。语法刻意很小——"" 或
"all" 放行,"admin" 只给 admin,"admin,user" 列角色。**"*" 不是 all 的同义词**:
一个在配置文件里看起来受限、实际放行一切的东西,正是会静默失效的那种设置。
覆盖走 config.yaml 的 plugin_visibility,因为插件作者不知道本部署的策略,而运维
也不该为了改一行去编辑内置 .lua(下次升级就没了)。
过滤发生在 UIForRole 而非 HTTP 层:WebUI 启动载荷、Electron 外壳、测试都读同一
处,只在 HTTP 层过滤意味着其它消费者照旧全量泄漏。
角色可见性回答不了"哪一部分"。billing 里全局数字(total、by_source、by_model、
未定价告警)是部署数据,by_key / by_day_key 描述的是**一个**调用方。于是插件用
state_scope 声明哪些桶是全局的、哪些按调用方过滤,深度由插件给出(1 = {maskedKey:
桶},2 = {day: {maskedKey: 桶}})——两种形状都是 map,靠嗅探分不清"空桶"和"嵌套桶"。
不匹配的行**删除而不是置零**:置零会渲染成"你没花钱",还会泄露"别人有这一行"。
billing 因此改为 visibility="all" + state_scope,而不是 admin-only —— 后者会让
split 失去意义,而前者不加 split 正是这次要堵的泄露。
有些东西值得从浏览器**外面**查(CI 判断上游余额是否为零、监控探针、盒子上的
另一个工具)。billing 从不需要(它只收 hook),quota 需要。为每个插件硬编码一条
路由意味着每次都要改网关,而把任意插件代码塞进主 mux 也不是路由表该做的事。
插件在 plugin.http 里声明 path/method/roles/keys,挂在 /api/plugins/http/ 下。
请求与响应都是普通 table 穿过 Go/Lua 边界。两条由 Go 侧而非信任插件来保证的性质:
status 与 method 走白名单(否则插件能选一个 3xx + Location 把调用者弹到它指定的
URL),Content-Length 与逐跳头被丢弃(否则插件能让网关的响应分帧错位)。
roles 太粗:无法表达"给 homeagent 开放、其他人不开"。keys 按**密钥名**授权而非密钥
值——密钥值会被轮换,规则写死在上面会静默失配;把凭据散进插件文件也是问题;而且
运维读的是名字。
**keys 非空时它是全部规则**,不是与 roles 的并集。写成并集的话,运维写两条允许名单
就会顺带放行所有 user 密钥,正好与意图相反。admin 永远放行,否则名单里一个拼写
错误会让唯一能诊断它的人也进不去。
写入前校验密钥名真实存在:一份写着错别字名字的名单会被存下、保存、在 WebUI 里
显示为"已配置",然后拒绝每一个调用者——失败看起来像端点坏了,而不是名字错了。
readPluginUI / readPluginExports / readPluginHTTP 都在插件**注册期间**调用
luaToJSON,而它依赖 setupGlobals 才安装的 json 全局,于是静默返回空值。三处表现各
不同(页面字段为空、exports 看不见、端点数为 0),同一个原因。现在统一走
luaTableToGoValue。
readPluginHTTP 还额外踩了 golua 的 GetField 不做字符串→数字转换,用索引循环取数组
永远拿到 nil。
去掉角色过滤 → 3 条判据红(含"a user key can see the admin-only page");
去掉 page 级 audience → 1 条红。routes 层断言徽标齐全、方法与 path 校验、栈帧
归属、admin 豁免、{status=204} 合法、空表报错。
生产验证:admin 看到 14 个字段 / by_key 5 行 / total $8.86;user 看到 0 行。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
(cherry picked from commit a6c6e6e5c7)
512 lines
16 KiB
Go
512 lines
16 KiB
Go
package lua
|
|
|
|
// Plugin-registered HTTP endpoints.
|
|
//
|
|
// The gateway mounts what a plugin declares in plugin.http and calls back into
|
|
// the plugin's Lua state to produce each response. This is the piece billing
|
|
// never needed: it only receives hooks, so it has nothing to expose. A plugin
|
|
// that computes something an operator wants to query from outside the browser —
|
|
// a health probe, a CI check, another tool — needs a route, and hard-coding one
|
|
// per plugin would mean a gateway change for every such plugin.
|
|
//
|
|
// The request and response are both plain tables crossing the Go/Lua boundary:
|
|
//
|
|
// request { method, path, query{}, params{}, headers{}, body, role, key }
|
|
// response { status, headers{}, body } or { status, json = <any table> }
|
|
//
|
|
// A handler returns (response, nil) or (nil, errorMessage). Errors never raise:
|
|
// an escaping Lua error inside a hook has taken the whole gateway down before
|
|
// (see hookGuardSrc), so the same discipline applies here.
|
|
//
|
|
// Two properties are enforced on the Go side rather than trusted to the plugin:
|
|
//
|
|
// - Method and status are checked against an allowlist before the response is
|
|
// written. A plugin returning status 0 or a redirect with an injected
|
|
// Location must not be able to steer the gateway's own routing.
|
|
// - Path traversal cannot escape the mount: params are substituted into a
|
|
// fixed segment count, and the handler's own Path is rejected at
|
|
// registration if it contains "..".
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// allowedMethods is deliberately short. An arbitrary verb would need a case in
|
|
// the router for no benefit, and a plugin that wants every verb can register the
|
|
// five that exist.
|
|
var allowedMethods = map[string]bool{
|
|
http.MethodGet: true, http.MethodPost: true, http.MethodPut: true,
|
|
http.MethodDelete: true, http.MethodPatch: true,
|
|
}
|
|
|
|
// normalizeHandlerMethod validates and defaults a declared method.
|
|
func normalizeHandlerMethod(m string) (string, error) {
|
|
m = strings.ToUpper(strings.TrimSpace(m))
|
|
if m == "" {
|
|
return http.MethodGet, nil
|
|
}
|
|
if !allowedMethods[m] {
|
|
return "", fmt.Errorf("unsupported method %q (allowed: GET POST PUT DELETE PATCH)", m)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// validateHandlerPath rejects paths that could escape the mount or collide with
|
|
// the plugin state/call routes.
|
|
func validateHandlerPath(p string) error {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" {
|
|
return fmt.Errorf("empty path")
|
|
}
|
|
if strings.Contains(p, "..") {
|
|
return fmt.Errorf("path %q contains .. ", p)
|
|
}
|
|
if strings.Contains(p, "//") {
|
|
return fmt.Errorf("path %q contains an empty segment", p)
|
|
}
|
|
for _, seg := range strings.Split(strings.Trim(p, "/"), "/") {
|
|
if seg == "" {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(seg, ":") {
|
|
if len(seg) == 1 {
|
|
return fmt.Errorf("path %q has a nameless param segment", p)
|
|
}
|
|
continue
|
|
}
|
|
// A literal segment must not look like a wildcard, or the route table
|
|
// gets two patterns that match the same URL.
|
|
if strings.ContainsAny(seg, "*?") {
|
|
return fmt.Errorf("path %q uses a wildcard; use :name for a param", p)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// handlerRequest is what a Lua handler sees.
|
|
type handlerRequest struct {
|
|
Method string
|
|
Path string
|
|
Query map[string]string
|
|
Params map[string]string
|
|
Headers map[string]string
|
|
Body string
|
|
Role string
|
|
Key string
|
|
}
|
|
|
|
// handlerResponse is what a Lua handler returns.
|
|
type handlerResponse struct {
|
|
Status int
|
|
Headers map[string]string
|
|
Body string
|
|
// JSON is set when the handler returned {json = <table>}; it is encoded by
|
|
// the kernel so a plugin does not hand-roll escaping.
|
|
JSON interface{}
|
|
}
|
|
|
|
// writeHTTPHandlerResponse renders a handler's reply onto the gateway's writer.
|
|
//
|
|
// The status is validated even though it came from a plugin: a handler
|
|
// returning 0 or 999 would make net/http panic, and one returning 3xx with a
|
|
// Location the plugin chose could be used to bounce a caller at a URL of the
|
|
// plugin's picking. Anything outside 100..599 that is not a redirect becomes 500
|
|
// with an error body rather than a panic.
|
|
func writeHTTPHandlerResponse(w http.ResponseWriter, r *handlerResponse) {
|
|
status := r.Status
|
|
if status == 0 {
|
|
status = http.StatusOK
|
|
}
|
|
if status < 100 || status > 599 {
|
|
status = http.StatusInternalServerError
|
|
}
|
|
for k, v := range r.Headers {
|
|
// A plugin cannot set Content-Length (the body is written by the kernel)
|
|
// or hop-by-hop headers (Connection, Transfer-Encoding) which would
|
|
// corrupt the gateway's own response framing.
|
|
switch http.CanonicalHeaderKey(k) {
|
|
case "Content-Length", "Connection", "Transfer-Encoding", "Upgrade":
|
|
continue
|
|
}
|
|
w.Header().Set(k, v)
|
|
}
|
|
if r.JSON != nil {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
// Encoded here rather than by the gateway layer: this file is in the lua
|
|
// package and importing the gateway would be a cycle.
|
|
if enc, err := json.Marshal(r.JSON); err == nil {
|
|
_, _ = w.Write(enc)
|
|
} else {
|
|
_, _ = w.Write([]byte(`{"error":"handler json could not be encoded"}`))
|
|
}
|
|
return
|
|
}
|
|
if w.Header().Get("Content-Type") == "" {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
}
|
|
w.WriteHeader(status)
|
|
if r.Body != "" {
|
|
_, _ = w.Write([]byte(r.Body))
|
|
}
|
|
}
|
|
|
|
// parseHandlerResponse reads a Lua handler's return value.
|
|
//
|
|
// It accepts three shapes, because a plugin should not have to think about it:
|
|
//
|
|
// { status = 200, body = "text" }
|
|
// { status = 201, json = { any = "table" } }
|
|
// { status = 204 } -- no body
|
|
//
|
|
// Anything else — a string, a number, a table without status — is a handler bug
|
|
// and becomes an error rather than an empty 200, so the plugin author sees it.
|
|
func parseHandlerResponse(v interface{}) (*handlerResponse, error) {
|
|
m, ok := v.(map[string]interface{})
|
|
if !ok {
|
|
return nil, fmt.Errorf("handler must return a table, got %T", v)
|
|
}
|
|
out := &handlerResponse{Status: http.StatusOK}
|
|
if s, ok := m["status"]; ok {
|
|
switch n := s.(type) {
|
|
case float64:
|
|
out.Status = int(n)
|
|
case int:
|
|
out.Status = n
|
|
default:
|
|
return nil, fmt.Errorf("status must be a number, got %T", s)
|
|
}
|
|
}
|
|
if b, ok := m["body"].(string); ok {
|
|
out.Body = b
|
|
}
|
|
if h, ok := m["headers"].(map[string]interface{}); ok {
|
|
out.Headers = make(map[string]string, len(h))
|
|
for k, hv := range h {
|
|
out.Headers[k] = asString(hv)
|
|
}
|
|
}
|
|
if j, ok := m["json"]; ok && j != nil {
|
|
out.JSON = j
|
|
}
|
|
// An empty table is a handler bug — it means the author forgot to say what to
|
|
// return. A table carrying ONLY a status is not: `{status=204}` is the normal
|
|
// way to answer with no body, and treating it as an error would make the
|
|
// correct spelling unusable.
|
|
if out.Body == "" && out.JSON == nil && out.Headers == nil && len(m) == 0 {
|
|
return nil, fmt.Errorf("handler returned an empty table; return " +
|
|
"{status=…, body=…}, {status=…, json=…}, or {status=…} for no body")
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// InvokeHandler calls a plugin's HTTP handler function with a request table and
|
|
// returns the decoded response.
|
|
//
|
|
// It reuses the same guard-in-Lua contract as the hooks: the function is called
|
|
// through __llmsproxy_call_hook, so a plugin error comes back as (nil, message)
|
|
// and never as a C-level abort.
|
|
func (ps *Plugins) InvokeHandler(plugin, fnName string, req *handlerRequest) (*handlerResponse, error) {
|
|
p := ps.find(plugin)
|
|
if p == nil {
|
|
return nil, fmt.Errorf("plugin %s not found", plugin)
|
|
}
|
|
p.mu.Lock()
|
|
w := p.state
|
|
p.mu.Unlock()
|
|
if w == nil {
|
|
return nil, fmt.Errorf("plugin %s has no state", plugin)
|
|
}
|
|
|
|
// The whole call happens under the plugin lock, network waits included —
|
|
// same property as Call, and for the same reason: one Lua state.
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
L := w.L
|
|
L.SetTop(0)
|
|
defer L.SetTop(0)
|
|
|
|
L.GetGlobal(pluginGlobal)
|
|
if L.IsNil(-1) {
|
|
return nil, fmt.Errorf("plugin table missing")
|
|
}
|
|
plug := L.GetTop()
|
|
L.GetGlobal(hookGuardName)
|
|
if L.IsNil(-1) {
|
|
L.Pop(1)
|
|
}
|
|
L.GetField(plug, fnName)
|
|
if !L.IsFunction(-1) {
|
|
return nil, fmt.Errorf("handler %s not found in plugin %s", fnName, plugin)
|
|
}
|
|
|
|
pushGoValue(L, plainForLua(map[string]interface{}{
|
|
"method": req.Method,
|
|
"path": req.Path,
|
|
"query": stringMapToAny(req.Query),
|
|
"params": stringMapToAny(req.Params),
|
|
"headers": stringMapToAny(req.Headers),
|
|
"body": req.Body,
|
|
"role": req.Role,
|
|
"key": req.Key,
|
|
}))
|
|
if err := L.Call(2, 2); err != nil {
|
|
return nil, err
|
|
}
|
|
errMsg := ""
|
|
if L.GetTop() >= 2 {
|
|
if !L.IsNil(-1) {
|
|
errMsg = L.ToString(-1)
|
|
}
|
|
L.Pop(1)
|
|
}
|
|
if errMsg != "" {
|
|
return nil, fmt.Errorf("plugin %s: %s", plugin, errMsg)
|
|
}
|
|
if L.GetTop() < 1 || L.IsNil(-1) {
|
|
return nil, fmt.Errorf("handler %s returned nothing", fnName)
|
|
}
|
|
var raw interface{}
|
|
if err := luaToJSON(L, -1, &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
return parseHandlerResponse(raw)
|
|
}
|
|
|
|
func stringMapToAny(m map[string]string) map[string]interface{} {
|
|
if len(m) == 0 {
|
|
return map[string]interface{}{}
|
|
}
|
|
out := make(map[string]interface{}, len(m))
|
|
for k, v := range m {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
// HandlerRequest is the request table handed to a plugin HTTP handler.
|
|
// Exported because the gateway package constructs it.
|
|
type HandlerRequest = handlerRequest
|
|
|
|
// HandlerResponse is what a handler returns, after decoding.
|
|
type HandlerResponse = handlerResponse
|
|
|
|
// WriteHandlerResponse renders a handler response onto the gateway's writer.
|
|
func WriteHandlerResponse(w http.ResponseWriter, r *HandlerResponse) {
|
|
writeHTTPHandlerResponse(w, r)
|
|
}
|
|
|
|
// HTTPHandlers returns the registered endpoints across all plugins, each tagged
|
|
// with its owner so the dispatcher knows which Lua state to enter.
|
|
func (ps *Plugins) HTTPHandlers() []HTTPHandler {
|
|
ps.mu.RLock()
|
|
defer ps.mu.RUnlock()
|
|
var out []HTTPHandler
|
|
for _, p := range ps.plugins {
|
|
if p.http == nil || p.LoadError != "" {
|
|
continue
|
|
}
|
|
for _, declared := range p.http {
|
|
// effectiveAccess applies the operator's per-endpoint override, so the
|
|
// dispatcher enforces what the WebUI shows rather than what the .lua
|
|
// declared.
|
|
h := ps.effectiveAccess(p, declared)
|
|
h.Plugin = p.Info.Name
|
|
out = append(out, h)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Allows is the exported form of the access rule, so the gateway dispatcher does
|
|
// not need a duplicate copy of it in its own package.
|
|
func (h HTTPHandler) Allows(role, keyName string) bool { return h.allows(role, keyName) }
|
|
|
|
// HTTPEndpointAccess is the operator's view of one endpoint's permissions,
|
|
// returned by /api/plugins so the WebUI can render what is exposed without
|
|
// parsing .lua files.
|
|
type HTTPEndpointAccess struct {
|
|
Plugin string `json:"plugin"`
|
|
Path string `json:"path"`
|
|
FullPath string `json:"full_path"`
|
|
Method string `json:"method"`
|
|
Handler string `json:"handler"`
|
|
Roles []string `json:"roles,omitempty"`
|
|
Keys []string `json:"keys,omitempty"`
|
|
Public bool `json:"public"`
|
|
Description string `json:"description,omitempty"`
|
|
// OverrideRoles / OverrideKeys carry the operator's own restriction, which
|
|
// REPLACES whatever the plugin declared. Empty means "use the plugin's".
|
|
//
|
|
// This is the mechanism the admin uses to narrow (or widen) an endpoint's
|
|
// reachable keys without editing the plugin: a bundled .lua is overwritten on
|
|
// the next upgrade, and a hand-installed one is not something the operator
|
|
// should have to maintain.
|
|
OverrideRoles []string `json:"override_roles,omitempty"`
|
|
OverrideKeys []string `json:"override_keys,omitempty"`
|
|
}
|
|
|
|
// endpointAccess returns the access table for every registered endpoint, with
|
|
// operator overrides folded in.
|
|
func (ps *Plugins) endpointAccess() []HTTPEndpointAccess {
|
|
ps.mu.RLock()
|
|
defer ps.mu.RUnlock()
|
|
var out []HTTPEndpointAccess
|
|
for _, p := range ps.plugins {
|
|
if p.http == nil || p.LoadError != "" {
|
|
continue
|
|
}
|
|
ov := ps.visibility[p.Info.Name]
|
|
var ovRoles, ovKeys []string
|
|
if ov != "" {
|
|
for _, part := range strings.Split(ov, ",") {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
// A key name is anything that is not a role: the override string
|
|
// carries both, so "@name" marks a key and a bare word a role.
|
|
if strings.HasPrefix(part, "@") {
|
|
ovKeys = append(ovKeys, strings.TrimPrefix(part, "@"))
|
|
} else {
|
|
ovRoles = append(ovRoles, part)
|
|
}
|
|
}
|
|
}
|
|
for _, h := range p.http {
|
|
out = append(out, HTTPEndpointAccess{
|
|
Plugin: p.Info.Name, Path: h.Path, FullPath: h.routePath(),
|
|
Method: h.Method, Handler: h.Handler,
|
|
Roles: h.Roles, Keys: h.Keys, Public: h.Public,
|
|
Description: h.Description,
|
|
OverrideRoles: ovRoles, OverrideKeys: ovKeys,
|
|
})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// EndpointAccess exposes the table to the gateway package.
|
|
func (ps *Plugins) EndpointAccess() []HTTPEndpointAccess { return ps.endpointAccess() }
|
|
|
|
// SetEndpointAccess narrows or widens one endpoint's reachable keys/roles.
|
|
//
|
|
// It is an OVERRIDE, applied on top of what the plugin declared, and stored in
|
|
// the same per-plugin visibility map the UI audience uses. An empty value clears
|
|
// it. Overriding per endpoint rather than per plugin is what the operator
|
|
// actually wants: /health is fine for every key while /balance is not, and
|
|
// those two live in the same plugin.
|
|
func (ps *Plugins) SetEndpointAccess(plugin, path, spec string) error {
|
|
p := ps.find(plugin)
|
|
if p == nil {
|
|
return fmt.Errorf("plugin %s not found", plugin)
|
|
}
|
|
found := false
|
|
for _, h := range p.http {
|
|
if h.Path == path {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return fmt.Errorf("plugin %s has no endpoint %q", plugin, path)
|
|
}
|
|
ps.mu.Lock()
|
|
defer ps.mu.Unlock()
|
|
if ps.visibility == nil {
|
|
ps.visibility = visibilityOverrides{}
|
|
}
|
|
key := endpointOverrideKey(plugin, path)
|
|
if strings.TrimSpace(spec) == "" {
|
|
delete(ps.visibility, key)
|
|
return nil
|
|
}
|
|
ps.visibility[key] = spec
|
|
return nil
|
|
}
|
|
|
|
// endpointOverrideKey namespaces a per-endpoint override so it cannot collide
|
|
// with a whole-plugin audience override of the same plugin name.
|
|
func endpointOverrideKey(plugin, path string) string {
|
|
return plugin + "#" + path
|
|
}
|
|
|
|
// effectiveAccess applies an endpoint override to the declared handler.
|
|
func (ps *Plugins) effectiveAccess(p *Plugin, h HTTPHandler) HTTPHandler {
|
|
ps.mu.RLock()
|
|
ov := ps.visibility[endpointOverrideKey(p.Info.Name, h.Path)]
|
|
ps.mu.RUnlock()
|
|
if ov == "" {
|
|
return h
|
|
}
|
|
out := h
|
|
out.Roles, out.Keys = nil, nil
|
|
out.Public = false
|
|
for _, part := range strings.Split(ov, ",") {
|
|
part = strings.TrimSpace(part)
|
|
switch {
|
|
case part == "":
|
|
case part == "public":
|
|
out.Public = true
|
|
case strings.HasPrefix(part, "@"):
|
|
out.Keys = append(out.Keys, strings.TrimPrefix(part, "@"))
|
|
default:
|
|
out.Roles = append(out.Roles, part)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// knownKeyNames is set by the gateway during wiring so ValidateAccessSpec can
|
|
// refuse an allowlist naming a key that does not exist.
|
|
//
|
|
// Without the check, an allowlist naming a typo ("homeagent" vs "home_agent")
|
|
// is stored, saved, shown in the WebUI as configured — and refuses every caller,
|
|
// including the one the operator meant. The failure looks like the endpoint being
|
|
// broken rather than the name being wrong.
|
|
var knownKeyNames func() []string
|
|
|
|
// SetKeyNameLookup registers the source of configured gateway key names.
|
|
func SetKeyNameLookup(fn func() []string) { knownKeyNames = fn }
|
|
|
|
// ValidateAccessSpec checks an access spec against the configured keys and roles.
|
|
//
|
|
// Roles are checked against the two that exist (admin, user) rather than left
|
|
// free-form: a role that does not exist produces an endpoint nobody can reach,
|
|
// which is the same silent failure as a bad key name.
|
|
func (ps *Plugins) ValidateAccessSpec(spec string) error {
|
|
spec = strings.TrimSpace(spec)
|
|
if spec == "" {
|
|
return nil
|
|
}
|
|
known := map[string]bool{"public": true, "admin": true, "user": true}
|
|
if knownKeyNames != nil {
|
|
for _, n := range knownKeyNames() {
|
|
if n != "" {
|
|
known[strings.ToLower(n)] = true
|
|
}
|
|
}
|
|
}
|
|
for _, part := range strings.Split(spec, ",") {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
name := part
|
|
if strings.HasPrefix(name, "@") {
|
|
name = strings.TrimPrefix(name, "@")
|
|
}
|
|
if name == "" {
|
|
return fmt.Errorf("access entry %q names no key", part)
|
|
}
|
|
if !known[strings.ToLower(name)] {
|
|
return fmt.Errorf("access entry %q is neither a role (admin/user/public) "+
|
|
"nor a configured key name", part)
|
|
}
|
|
}
|
|
return nil
|
|
}
|