openplatform

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 10, 2026 License: AGPL-3.0 Imports: 18 Imported by: 0

README

StuHelper Open Platform

第三方应用审批、逐项用户授权与撤回通知模块。模块拥有独立 PostgreSQL schema open_platform,业务数据通过公开接口读取。使用 AGPL-3.0-only。

当前实现

开发者提交名称、用途、HTTPS 回调与信息范围。管理员逐项审批并设置授权期限、令牌期限和 UTC 日调用配额。登录不会自动授予业务信息权限。

稳定范围为 student_status、schools、qq_binding_status、qq_binding_details、profile、schedule、catalog、public_reviews。目录与公开评价仍由提供方执行当前内容准入。课表读取本人有效选课记录对应的排课,空 meetings 表示没有提供排课,不能推断用户空闲。

QQ 两档分别申请、审批和授权,同意页 qq_tiers 表明“绑定状态”或“绑定状态和号码”。状态档不返回号码。两档必须来自可信绑定来源,缺失或故障返回 503。

授权使用单次授权码与 PKCE S256,精确匹配登记回调。每个账号最多保留 100 个未过期、未决定的授权请求。用户会话创建 5 分钟授权请求,服务端绑定本人、应用修订、范围、回调和 state。决定返回回调 URL,客户端必须核验 state 与 iss。授权码有效 60 秒。

应用类型为 public 或 confidential。两类均要求 PKCE,confidential 还要求 HTTP Basic 应用凭据。访问令牌最长 1 小时,不发行刷新令牌;交换结果不明时重新发起授权。业务令牌不接受作为用户会话。

应用凭据仅首次轮换返回明文,持久化哈希。相同幂等键重试返回 retrievable=false;丢失响应后读取修订并重新轮换。授权码由私钥 MAC 派生,库中只存哈希,幂等结果不包含 code。CodeKey 必须为 32 随机字节,由私有配置管理。

每次业务读取前后检查当前应用、授权、期限和账号状态。撤回范围使已有令牌立即失效;保留范围需要新授权码换取新令牌。管理员变更使原授权失效,重新启用需用户重新授权。

注销在同一事务封存授权账号、停用名下应用。每个开发者最多 100 个应用。恢复由管理员另行处理;模块没有自动复活账号或旧令牌的入口。

Work 每批最多 100 条、最多 10 秒,为到期、停用与注销授权生成通知。即时拒绝读取不依赖后台进度。第三方通过应用凭据或开发者会话分页读取通知,顺序游标支持继续读取后续通知。

通知支持 received、deleted 和 retention_exception 回报,法定保留例外必须填写理由。seen_at 仅表示读取,deleted 仅记录第三方声明。已停用应用的凭据只可读取和反馈通知,不能恢复数据权限。

列表每页最多 100 条。提供方调用限时 3 秒,数据库操作限时 5 秒。应用日配额在真实数据库原子扣减,授权后的调用尝试包括提供方失败均计入。模块不缓存第三方读取的业务数据。

业务记录、必要审计与幂等结果同事务提交。

Cleanup 每类最多清理 100 条、总计最多 600 条,整轮最长 5 秒。幂等响应完成 24 小时后清理,过期令牌即时清理,授权请求过期 24 小时后清理,事件与通知保留 12 个月,日配额保留当前与前一 UTC 日期。数据库备份与保留由私有运维策略管理,不增加付费存储或上传检查产物。

接口与检查

接口定义见 OpenAPI,生成客户端位于 sdk。运行 make generate 更新客户端,make check 检查生成一致性、仓库边界、文档、竞态与真实 PostgreSQL。

检查需要 STUHELPER_OPEN_PLATFORM_TEST_DSN 指向隔离测试服务器。CI 使用最新稳定 PostgreSQL。业务运行身份仅获得本 schema 的表权限,requests、events 和 tokens 允许 SELECT、INSERT、DELETE,禁止 UPDATE,序列只允许 USAGE。迁移身份单独使用。

验收场景

场景 检查
OP-01:申请和审批 非管理员拒绝、未审批拒绝、批准范围与用户范围交集、回调和 PKCE 绑定、事务回滚
OP-03:授权失效 部分撤回、在途读取撤回、单次交换、过期、配额、账号注销与可恢复通知
OP-04:登录不自动授权 用户会话不能替代业务令牌,伪造 owner 和未知字段被拒绝

真实账号、第三方应用联调和生产迁移未验收。后端组合负责连接事实提供方并启动有界后台处理。本仓不包含客户端页面或生产配置。

授权码重放在验证应用、回调和 PKCE 后撤销该授权码签发的令牌。错误证明不能撤销令牌,其他应用授权不受影响。profile.id 与返回 subject 相同,按应用隔离,不返回全局个人主页 ID。

QQ 来源由 qq-binding 提供。三项独立身份全部验证后才返回绑定完成,详情范围仅包含 OneBot 的 QQ 号,不含 openid 或 unionid。任一项解绑后返回未完成且不返回号码;来源故障返回 503。

公开审计查询

AUDIT-01:postgres.Repository.ReadAudit(ctx, auditquery.Request) 返回 auditquery.Batch。backend 适配器负责认证,将当前授权与查询范围求交,再传入 Scopes。Repository 按范围并集与全部条件过滤后分页。

时间为左闭右开区间,每次最多 101 条,按时间、模块、ID 字节序降序。Before 使用全局位置。Snapshot 固定 PostgreSQL 已提交成员,排除回填和延迟提交;删除或修改审计使旧快照失效,调用方须重新查询。

摘要只含模块、内部对象和操作者标识、时间、操作、原因、结果与范围。学校或群不明时使用 *,仅对应全局权限可见。没有原因时为空,超出 512 字节或含控制字符的原因省略;无法证明结果时为 unknown。

表结构直接定义最终形态,不转换历史数据。审计与业务同事务写入。运行身份需要读取 audit_retention,清理修订由固定 schema 的触发器更新。快照超过 256 字节或无效时明确报错。

postgres/audit_query_test.go 的 TestPublicAuditQuery 使用独立 PostgreSQL 数据库,验证条件筛选、范围、空原因、unknown、脱敏、回填与延迟提交、清理失效和取消。OpenAPI 路径保持现状,本接口供可信 Go 装配使用。

场景 验证条件
AUDIT-01 公开审计按授权过滤、稳定分页、脱敏及快照失效;运行身份不能删除审计

Documentation

Overview

Package openplatform owns approved applications and separately consented business grants.

Index

Constants

This section is empty.

Variables

View Source
var (
	ErrInput           = errors.New("invalid_input")
	ErrUnauthenticated = errors.New("unauthenticated")
	ErrForbidden       = errors.New("forbidden")
	ErrNotFound        = errors.New("not_found")
	ErrConflict        = errors.New("conflict")
	ErrUnavailable     = errors.New("unavailable")
	ErrQuota           = errors.New("quota_exceeded")
	ErrClient          = errors.New("invalid_client")
)
View Source
var Scopes = []string{"student_status", "schools", "qq_binding_status", "qq_binding_details", "profile", "schedule", "catalog", "public_reviews"}

Functions

func OpenAPI

func OpenAPI() []byte

Types

type Acknowledgement

type Acknowledgement struct {
	Status string `json:"status"`
	Note   string `json:"note"`
}

type AppRecord

type AppRecord struct {
	View              Application
	Owner, SecretHash string
}

type Application

type Application struct {
	ID                 string    `json:"id"`
	Name               string    `json:"name"`
	Purpose            string    `json:"purpose"`
	RedirectURIs       []string  `json:"redirect_uris"`
	RequestedScopes    []string  `json:"requested_scopes"`
	ApprovedScopes     []string  `json:"approved_scopes"`
	ClientType         string    `json:"client_type"`
	State              string    `json:"state"`
	Note               string    `json:"note"`
	Revision           int64     `json:"revision"`
	CredentialRevision int64     `json:"credential_revision"`
	Policy             Policy    `json:"policy"`
	CreatedAt          time.Time `json:"created_at"`
}

type ApplicationInput

type ApplicationInput struct {
	Name            string   `json:"name"`
	Purpose         string   `json:"purpose"`
	RedirectURIs    []string `json:"redirect_uris"`
	RequestedScopes []string `json:"requested_scopes"`
	ClientType      string   `json:"client_type"`
	Revision        int64    `json:"revision,omitempty"`
}

type AuthorizationResult

type AuthorizationResult struct {
	RedirectURI string `json:"redirect_uri"`
}

type CatalogItem

type CatalogItem struct {
	ID           string `json:"id"`
	Kind         string `json:"kind"`
	SchoolID     string `json:"school_id"`
	Name         string `json:"name"`
	CourseID     string `json:"course_id,omitempty"`
	TermID       string `json:"term_id,omitempty"`
	DepartmentID string `json:"department_id,omitempty"`
	Biography    string `json:"biography,omitempty"`
	Revision     int64  `json:"revision"`
}
type Consent struct {
	QQTiers     []string    `json:"qq_tiers"`
	ID          string      `json:"id"`
	Application Application `json:"application"`
	Scopes      []string    `json:"requested_scopes"`
	RedirectURI string      `json:"redirect_uri"`
	ExpiresAt   time.Time   `json:"expires_at"`
}

type ConsentDecision

type ConsentDecision struct {
	Decision string   `json:"decision"`
	Scopes   []string `json:"scopes"`
}

type ConsentInput

type ConsentInput struct {
	ClientID    string   `json:"client_id"`
	RedirectURI string   `json:"redirect_uri"`
	State       string   `json:"state"`
	Scopes      []string `json:"scope"`
	Challenge   string   `json:"code_challenge"`
	Method      string   `json:"code_challenge_method"`
}

type ConsentRecord

type ConsentRecord struct {
	View          Consent
	Owner         string
	Input         ConsentInput
	Decision      string
	Selected      []string
	CodeHash      string
	CodeExpiresAt time.Time
	Consumed      bool
	GrantID       string
	GrantRevision int64
}

type Credential

type Credential struct {
	AppID       string `json:"app_id"`
	Revision    int64  `json:"revision"`
	Secret      string `json:"client_secret,omitempty"`
	Retrievable bool   `json:"retrievable"`
}

type Data

type Data struct {
	Subject       string          `json:"subject"`
	Scope         string          `json:"scope"`
	StudentStatus *StudentStatus  `json:"student_status,omitempty"`
	Schools       *[]School       `json:"schools,omitempty"`
	QQBinding     *QQBinding      `json:"qq_binding,omitempty"`
	Profile       *Profile        `json:"profile,omitempty"`
	Schedule      *[]ScheduleItem `json:"schedule,omitempty"`
	Catalog       *[]CatalogItem  `json:"catalog,omitempty"`
	Reviews       *[]ReviewItem   `json:"public_reviews,omitempty"`
	Next          string          `json:"next,omitempty"`
}

Data uses closed projections so a provider cannot accidentally return raw credentials or verification material.

type Grant

type Grant struct {
	ID        string    `json:"id"`
	AppID     string    `json:"app_id"`
	AppName   string    `json:"app_name"`
	Subject   string    `json:"subject"`
	Scopes    []string  `json:"scopes"`
	State     string    `json:"state"`
	ExpiresAt time.Time `json:"expires_at"`
	Revision  int64     `json:"revision"`
}

type GrantRecord

type GrantRecord struct {
	View        Grant
	Owner       string
	AppRevision int64
}

type Notice

type Notice struct {
	Sequence        int64      `json:"sequence"`
	ID              string     `json:"id"`
	AppID           string     `json:"app_id"`
	Subject         string     `json:"subject"`
	Scopes          []string   `json:"scopes"`
	Reason          string     `json:"reason"`
	CreatedAt       time.Time  `json:"created_at"`
	SeenAt          *time.Time `json:"seen_at,omitempty"`
	Acknowledgement string     `json:"acknowledgement,omitempty"`
	Note            string     `json:"note,omitempty"`
	AcknowledgedAt  *time.Time `json:"acknowledged_at,omitempty"`
}

type Page

type Page[T any] struct {
	Items []T    `json:"items"`
	Next  string `json:"next,omitempty"`
}

type Policy

type Policy struct {
	GrantSeconds int `json:"grant_seconds"`
	TokenSeconds int `json:"token_seconds"`
	DailyQuota   int `json:"daily_quota"`
}

type Principal

type Principal struct {
	Owner string
	Admin bool
}

type Profile

type Profile struct {
	ID       string `json:"id"`
	Nickname string `json:"nickname"`
	Bio      string `json:"bio"`
}

type QQBinding

type QQBinding struct {
	Bound  bool   `json:"bound"`
	Number string `json:"number,omitempty"`
}

type Query

type Query struct {
	ID, Kind, Query, TermID, TargetID, After string
	Limit                                    int
}

type Repository

type Repository interface {
	Transaction(context.Context, string, string, func(Transaction) error) error
	LocateConsent(context.Context, string) (ConsentRecord, error)
	LocateCode(context.Context, string) (ConsentRecord, error)
	LocateToken(context.Context, string) (TokenRecord, GrantRecord, error)
	LocateGrant(context.Context, string) (GrantRecord, error)
	WorkCandidates(context.Context, time.Time, int) ([]GrantRecord, error)
}

type ReviewInput

type ReviewInput struct {
	Revision       int64    `json:"revision"`
	Decision       string   `json:"decision"`
	ApprovedScopes []string `json:"approved_scopes"`
	Policy         Policy   `json:"policy"`
	Note           string   `json:"note"`
}

type ReviewItem

type ReviewItem struct {
	ID        string    `json:"id"`
	Kind      string    `json:"kind"`
	TargetID  string    `json:"target_id"`
	Body      string    `json:"body"`
	Ratings   [5]int    `json:"ratings"`
	CreatedAt time.Time `json:"created_at"`
}

type RevokeInput

type RevokeInput struct {
	Revision int64    `json:"revision"`
	Scopes   []string `json:"scopes"`
}

type ScheduleItem

type ScheduleItem struct {
	ID         string            `json:"id"`
	CourseID   string            `json:"course_id"`
	CourseName string            `json:"course_name"`
	TermID     string            `json:"term_id"`
	ClassID    string            `json:"class_id"`
	Meetings   []ScheduleMeeting `json:"meetings"`
	FetchedAt  time.Time         `json:"fetched_at"`
	Active     bool              `json:"active"`
}

type ScheduleMeeting

type ScheduleMeeting struct {
	ID       string `json:"id"`
	TimeText string `json:"time_text"`
	Location string `json:"location"`
}

type School

type School struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

type Service

type Service struct {
	Repo    Repository
	Source  Source
	Now     func() time.Time
	Random  io.Reader
	CodeKey []byte
	Issuer  string
}

func (*Service) Acknowledge

func (s *Service) Acknowledge(ctx context.Context, p Principal, app, secret, id, key string, in Acknowledgement) (Notice, error)

func (*Service) Application

func (s *Service) Application(ctx context.Context, p Principal, id string) (Application, error)

func (*Service) Applications

func (s *Service) Applications(ctx context.Context, p Principal, admin bool, after string, limit int) (Page[Application], error)

func (*Service) Apply

func (s *Service) Apply(ctx context.Context, p Principal, id, key string, in ApplicationInput) (Application, error)

func (*Service) Cleanup

func (s *Service) Cleanup(ctx context.Context, limit int) (int, error)

Cleanup caps each data category independently so one backlog cannot starve others. At most six times limit rows are removed, with a five-second operation budget.

func (*Service) CloseAccount

func (s *Service) CloseAccount(ctx context.Context, owner, closureID string) error

CloseAccount installs a permanent grant-owner fence and application epochs in the same transaction. It is a trusted module port; there is no HTTP route that accepts another account key.

func (*Service) Consent

func (s *Service) Consent(ctx context.Context, p Principal, id string) (Consent, error)

func (*Service) Decide

func (s *Service) Decide(ctx context.Context, p Principal, id, key string, in ConsentDecision) (AuthorizationResult, error)

func (*Service) Exchange

func (s *Service) Exchange(ctx context.Context, in TokenInput) (Token, error)

func (*Service) Grants

func (s *Service) Grants(ctx context.Context, p Principal, after string, limit int) (Page[Grant], error)

func (*Service) Notices

func (s *Service) Notices(ctx context.Context, p Principal, app, secret, after string, limit int) (Page[Notice], error)

func (*Service) PrepareConsent

func (s *Service) PrepareConsent(ctx context.Context, p Principal, key string, in ConsentInput) (Consent, error)

func (*Service) Read

func (s *Service) Read(ctx context.Context, token, scope string, q Query) (Data, error)

func (*Service) Review

func (s *Service) Review(ctx context.Context, p Principal, id, key string, in ReviewInput) (Application, error)

func (*Service) Revoke

func (s *Service) Revoke(ctx context.Context, p Principal, id, key string, in RevokeInput) (Grant, error)

func (*Service) RotateCredential

func (s *Service) RotateCredential(ctx context.Context, p Principal, id, key string, revision int64) (Credential, error)

func (*Service) Validate

func (s *Service) Validate() error

Validate checks required dependencies and private protocol configuration.

func (*Service) Work

func (s *Service) Work(ctx context.Context, limit int) (int, error)

Work materializes at most limit pending termination notices. Reads already enforce the fence before this worker runs.

type Source

type Source interface {
	Read(context.Context, string, string, Query) (Data, error)
}

Source receives only the verified grant owner; content sources must enforce current admission.

type StudentStatus

type StudentStatus struct {
	Verified    bool      `json:"verified"`
	EvaluatedAt time.Time `json:"evaluated_at"`
}

type Token

type Token struct {
	AccessToken string `json:"access_token"`
	TokenType   string `json:"token_type"`
	ExpiresIn   int64  `json:"expires_in"`
	Scope       string `json:"scope"`
	Subject     string `json:"subject"`
}

type TokenInput

type TokenInput struct{ GrantType, ClientID, RedirectURI, Code, Verifier, ClientSecret string }

type TokenRecord

type TokenRecord struct {
	Hash, GrantID, ConsentID   string
	GrantRevision, AppRevision int64
	ExpiresAt                  time.Time
}

type Transaction

type Transaction interface {
	Closed(string) (bool, error)
	CloseOwner(string, string) error
	App(string) (AppRecord, error)
	Apps(string, bool, string, int) ([]AppRecord, error)
	SaveApp(AppRecord) error
	CountApps(string) (int, error)
	SuspendOwnedApps(string) error
	Consent(string) (ConsentRecord, error)
	PendingConsents(string, time.Time) (int, error)
	SaveConsent(ConsentRecord) error
	Grant(string) (GrantRecord, error)
	OwnerGrant(string, string) (GrantRecord, error)
	Grants(string, string, int) ([]GrantRecord, error)
	SaveGrant(GrantRecord) error
	Token(string) (TokenRecord, error)
	SaveToken(TokenRecord) error
	RevokeCodeTokens(string, string) error
	Quota(string, string, int) error
	Notice(string) (Notice, error)
	Notices(string, string, int) ([]Notice, error)
	SaveNotice(Notice) error
	Replay(string, string) (string, json.RawMessage, error)
	SaveReplay(string, string, string, json.RawMessage) error
	Event(string, string, string, string) error
}

Directories

Path Synopsis
Package auditquery defines the sanitized, authorized audit repository port.
Package auditquery defines the sanitized, authorized audit repository port.
Package sdk provides primitives to interact with the openapi HTTP API.
Package sdk provides primitives to interact with the openapi HTTP API.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL