mirror of
https://github.com/Quad4-Software/Reticulum-Go
synced 2026-08-29 23:48:44 -04:00
370 lines
8.7 KiB
Go
370 lines
8.7 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
// Copyright (c) 2024-2026 Quad4.io
|
|
|
|
package rnsgit
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/hex"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
permRead = 0x01
|
|
permWrite = 0x02
|
|
permCreate = 0x04
|
|
permStats = 0x05
|
|
permRelease = 0x06
|
|
permInteract = 0x07
|
|
permPropose = 0x08
|
|
permAdmin = 0xFE
|
|
|
|
tgtAll = "__all__"
|
|
tgtNone = "__none__"
|
|
)
|
|
|
|
// PermissionSet stores allowed identity hashes and special targets.
|
|
type PermissionSet struct {
|
|
All bool
|
|
None bool
|
|
IDs map[string]struct{}
|
|
Admins map[string]struct{}
|
|
}
|
|
|
|
// AccessTable resolves repository and group permissions.
|
|
type AccessTable struct {
|
|
Groups map[string]*GroupAccess
|
|
}
|
|
|
|
// GroupAccess holds group-level and per-repo permissions.
|
|
type GroupAccess struct {
|
|
Name string
|
|
Path string
|
|
Read PermissionSet
|
|
Write PermissionSet
|
|
Create PermissionSet
|
|
Stats PermissionSet
|
|
Release PermissionSet
|
|
Interact PermissionSet
|
|
Propose PermissionSet
|
|
Admin PermissionSet
|
|
Repositories map[string]*RepoAccess
|
|
}
|
|
|
|
// RepoAccess holds repository-level permissions.
|
|
type RepoAccess struct {
|
|
Name string
|
|
Path string
|
|
Read PermissionSet
|
|
Write PermissionSet
|
|
Create PermissionSet
|
|
Stats PermissionSet
|
|
Release PermissionSet
|
|
Interact PermissionSet
|
|
Propose PermissionSet
|
|
Admin PermissionSet
|
|
}
|
|
|
|
// NewAccessTable builds permissions from config and on-disk .allowed files.
|
|
func NewAccessTable(cfg *ServerConfig) (*AccessTable, error) {
|
|
t := &AccessTable{Groups: map[string]*GroupAccess{}}
|
|
for group, root := range cfg.RepositoryGroups {
|
|
abs, err := filepath.Abs(root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ga := &GroupAccess{
|
|
Name: group,
|
|
Path: abs,
|
|
Repositories: map[string]*RepoAccess{},
|
|
}
|
|
if rule, ok := cfg.AccessRules[group]; ok {
|
|
applyRules(&ga.Read, &ga.Write, &ga.Create, &ga.Stats, &ga.Release, &ga.Interact, &ga.Propose, &ga.Admin, rule)
|
|
}
|
|
groupAllowed := filepath.Join(filepath.Dir(abs), group+".allowed")
|
|
applyAllowedFile(ga, groupAllowed)
|
|
if err := os.MkdirAll(abs, 0o755); err != nil { // #nosec G301 -- git group directory
|
|
return nil, err
|
|
}
|
|
entries, _ := os.ReadDir(abs)
|
|
for _, ent := range entries {
|
|
if !ent.IsDir() || strings.HasPrefix(ent.Name(), ".") {
|
|
continue
|
|
}
|
|
repoPath := filepath.Join(abs, ent.Name())
|
|
ra := &RepoAccess{Name: ent.Name(), Path: repoPath}
|
|
inheritRepoFromGroup(ra, ga)
|
|
repoAllowed := filepath.Join(abs, ent.Name()+".allowed")
|
|
applyRepoAllowedFile(ra, repoAllowed)
|
|
ga.Repositories[ent.Name()] = ra
|
|
}
|
|
t.Groups[group] = ga
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
func inheritRepoFromGroup(ra *RepoAccess, ga *GroupAccess) {
|
|
ra.Read = ga.Read
|
|
ra.Write = ga.Write
|
|
ra.Create = ga.Create
|
|
ra.Stats = ga.Stats
|
|
ra.Release = ga.Release
|
|
ra.Interact = ga.Interact
|
|
ra.Propose = ga.Propose
|
|
ra.Admin = ga.Admin
|
|
}
|
|
|
|
func applyRepoAllowedFile(ra *RepoAccess, path string) {
|
|
lines := readAllowedLines(path)
|
|
if len(lines) == 0 {
|
|
return
|
|
}
|
|
ra.Read = emptyPerm()
|
|
ra.Write = emptyPerm()
|
|
ra.Create = emptyPerm()
|
|
ra.Stats = emptyPerm()
|
|
ra.Release = emptyPerm()
|
|
ra.Interact = emptyPerm()
|
|
ra.Propose = emptyPerm()
|
|
ra.Admin = emptyPerm()
|
|
applyRules(&ra.Read, &ra.Write, &ra.Create, &ra.Stats, &ra.Release, &ra.Interact, &ra.Propose, &ra.Admin, strings.Join(lines, "\n"))
|
|
}
|
|
|
|
func applyAllowedFile(ga *GroupAccess, path string) {
|
|
lines := readAllowedLines(path)
|
|
if len(lines) == 0 {
|
|
return
|
|
}
|
|
applyRules(&ga.Read, &ga.Write, &ga.Create, &ga.Stats, &ga.Release, &ga.Interact, &ga.Propose, &ga.Admin, strings.Join(lines, "\n"))
|
|
}
|
|
|
|
func readAllowedLines(path string) []string {
|
|
st, err := os.Stat(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if st.Mode()&0o111 != 0 {
|
|
out, err := exec.Command(path).Output() // #nosec G204 -- executable permission file
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return splitAllowed(string(out))
|
|
}
|
|
b, err := os.ReadFile(path) // #nosec G304 -- permission config
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return splitAllowed(string(b))
|
|
}
|
|
|
|
func splitAllowed(s string) []string {
|
|
var lines []string
|
|
for line := range strings.SplitSeq(s, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
lines = append(lines, line)
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func applyRules(read, write, create, stats, release, interact, propose, admin *PermissionSet, rule string) {
|
|
for part := range strings.SplitSeq(rule, ",") {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
perm, target, ok := strings.Cut(part, ":")
|
|
if !ok {
|
|
continue
|
|
}
|
|
perm = strings.ToLower(strings.TrimSpace(perm))
|
|
target = strings.TrimSpace(target)
|
|
sets := permSets(perm, read, write, create, stats, release, interact, propose, admin)
|
|
for _, ps := range sets {
|
|
addTarget(ps, target)
|
|
}
|
|
}
|
|
}
|
|
|
|
func permSets(perm string, read, write, create, stats, release, interact, propose, admin *PermissionSet) []*PermissionSet {
|
|
switch perm {
|
|
case "r", "read":
|
|
return []*PermissionSet{read}
|
|
case "w", "write":
|
|
return []*PermissionSet{write}
|
|
case "rw", "readwrite":
|
|
return []*PermissionSet{read, write}
|
|
case "c", "create":
|
|
return []*PermissionSet{create}
|
|
case "s", "stats":
|
|
return []*PermissionSet{stats}
|
|
case "rel", "release":
|
|
return []*PermissionSet{release}
|
|
case "i", "interact":
|
|
return []*PermissionSet{interact}
|
|
case "p", "propose":
|
|
return []*PermissionSet{propose}
|
|
case "adm", "admin":
|
|
return []*PermissionSet{admin}
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func addTarget(ps *PermissionSet, target string) {
|
|
target = strings.ToLower(target)
|
|
switch target {
|
|
case "all", "a", "everyone":
|
|
ps.All = true
|
|
case "none", "n", "nobody":
|
|
ps.None = true
|
|
default:
|
|
if len(target) == 32 {
|
|
if _, err := hex.DecodeString(target); err == nil {
|
|
if ps.IDs == nil {
|
|
ps.IDs = map[string]struct{}{}
|
|
}
|
|
ps.IDs[target] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func emptyPerm() PermissionSet {
|
|
return PermissionSet{IDs: map[string]struct{}{}}
|
|
}
|
|
|
|
func (t *AccessTable) Resolve(group, repo string, remoteHash []byte, perm int) bool {
|
|
if t == nil {
|
|
return false
|
|
}
|
|
ga, ok := t.Groups[group]
|
|
if !ok {
|
|
return false
|
|
}
|
|
hexID := hex.EncodeToString(remoteHash)
|
|
if repo != "" {
|
|
ra, ok := ga.Repositories[repo]
|
|
if !ok {
|
|
return false
|
|
}
|
|
return resolveRepo(ra, ga, hexID, perm)
|
|
}
|
|
return resolveSet(ga.permSet(perm), ga.Admin, hexID)
|
|
}
|
|
|
|
func resolveRepo(ra *RepoAccess, ga *GroupAccess, hexID string, perm int) bool {
|
|
ps := ra.permSet(perm)
|
|
if hasExplicit(ps) {
|
|
return resolveSet(ps, ra.Admin, hexID)
|
|
}
|
|
return resolveSet(ga.permSet(perm), ga.Admin, hexID) || inSet(ra.Admin, hexID)
|
|
}
|
|
|
|
func hasExplicit(ps PermissionSet) bool {
|
|
return ps.All || ps.None || len(ps.IDs) > 0
|
|
}
|
|
|
|
func resolveSet(ps PermissionSet, admins PermissionSet, hexID string) bool {
|
|
if ps.None {
|
|
return false
|
|
}
|
|
if ps.All {
|
|
return true
|
|
}
|
|
if inSet(ps, hexID) || inSet(admins, hexID) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func inSet(ps PermissionSet, hexID string) bool {
|
|
if ps.All {
|
|
return true
|
|
}
|
|
_, ok := ps.IDs[hexID]
|
|
return ok
|
|
}
|
|
|
|
func (ga *GroupAccess) permSet(perm int) PermissionSet {
|
|
switch perm {
|
|
case permRead:
|
|
return ga.Read
|
|
case permWrite:
|
|
return ga.Write
|
|
case permCreate:
|
|
return ga.Create
|
|
case permStats:
|
|
return ga.Stats
|
|
case permRelease:
|
|
return ga.Release
|
|
case permInteract:
|
|
return ga.Interact
|
|
case permPropose:
|
|
return ga.Propose
|
|
case permAdmin:
|
|
return ga.Admin
|
|
default:
|
|
return emptyPerm()
|
|
}
|
|
}
|
|
|
|
func (ra *RepoAccess) permSet(perm int) PermissionSet {
|
|
switch perm {
|
|
case permRead:
|
|
return ra.Read
|
|
case permWrite:
|
|
return ra.Write
|
|
case permCreate:
|
|
return ra.Create
|
|
case permStats:
|
|
return ra.Stats
|
|
case permRelease:
|
|
return ra.Release
|
|
case permInteract:
|
|
return ra.Interact
|
|
case permPropose:
|
|
return ra.Propose
|
|
case permAdmin:
|
|
return ra.Admin
|
|
default:
|
|
return emptyPerm()
|
|
}
|
|
}
|
|
|
|
// WriteAllowedFile saves permission lines to path.
|
|
func WriteAllowedFile(path string, content string) error {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { // #nosec G301 -- allowed file parent dir
|
|
return err
|
|
}
|
|
var buf bytes.Buffer
|
|
for line := range strings.SplitSeq(content, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
if _, _, ok := strings.Cut(line, ":"); !ok {
|
|
return os.ErrInvalid
|
|
}
|
|
buf.WriteString(line)
|
|
buf.WriteByte('\n')
|
|
}
|
|
return os.WriteFile(path, buf.Bytes(), 0o644) // #nosec G306 -- allowed file
|
|
}
|
|
|
|
// ReadAllowedFile returns the contents of an allowed file.
|
|
func ReadAllowedFile(path string) (string, error) {
|
|
lines := readAllowedLines(path)
|
|
return strings.Join(lines, "\n"), nil
|
|
}
|
|
|
|
// GrantCreatorAdmin writes admin permissions for a new repository creator.
|
|
func GrantCreatorAdmin(repoAllowedPath, creatorHex string) error {
|
|
content := "adm:" + creatorHex + "\nrw:" + creatorHex
|
|
return WriteAllowedFile(repoAllowedPath, content)
|
|
}
|